From 2ea5345a7b8a2cd1f47e784e9e977094cb2b8579 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Apr 2026 19:06:42 -0500 Subject: [PATCH 001/157] feat: new tui based on ink --- ...26-04-01-ink-gateway-tui-migration-plan.md | 1170 ++++++++++++++++ tui_gateway/__init__.py | 0 tui_gateway/entry.py | 36 + tui_gateway/server.py | 351 +++++ ui-tui/package-lock.json | 1213 +++++++++++++++++ ui-tui/package.json | 23 + ui-tui/src/altScreen.tsx | 29 + ui-tui/src/banner.ts | 43 + ui-tui/src/gatewayClient.ts | 72 + ui-tui/src/main.js | 46 + ui-tui/src/main.tsx | 1073 +++++++++++++++ ui-tui/src/theme.ts | 105 ++ ui-tui/tsconfig.json | 16 + 13 files changed, 4177 insertions(+) create mode 100644 docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md create mode 100644 tui_gateway/__init__.py create mode 100644 tui_gateway/entry.py create mode 100644 tui_gateway/server.py create mode 100644 ui-tui/package-lock.json create mode 100644 ui-tui/package.json create mode 100644 ui-tui/src/altScreen.tsx create mode 100644 ui-tui/src/banner.ts create mode 100644 ui-tui/src/gatewayClient.ts create mode 100644 ui-tui/src/main.js create mode 100644 ui-tui/src/main.tsx create mode 100644 ui-tui/src/theme.ts create mode 100644 ui-tui/tsconfig.json 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 0000000000..5692c5e7a8 --- /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 0000000000..e69de29bb2 diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py new file mode 100644 index 0000000000..72a4537f4f --- /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 0000000000..5a1c350698 --- /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 0000000000..a5b78523c3 --- /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 0000000000..52c400d421 --- /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 0000000000..f6c74888bd --- /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 0000000000..3d52c91379 --- /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 0000000000..95ae06b1c0 --- /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 0000000000..892853bc95 --- /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 0000000000..fa9d1bee41 --- /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 0000000000..0a212d4dfe --- /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 0000000000..fe135bfecb --- /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"] +} From 2818dd8611f5d2ae4722f02c9b0c0af3f8f9793e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Apr 2026 19:34:30 -0500 Subject: [PATCH 002/157] feat: add prettier etc for ui-tui --- tui_gateway/server.py | 12 +- ui-tui/.prettierrc | 11 + ui-tui/eslint.config.mjs | 68 + ui-tui/package-lock.json | 4161 ++++++++++++++++++++++++++++++++++- ui-tui/package.json | 15 +- ui-tui/src/altScreen.tsx | 4 +- ui-tui/src/banner.ts | 11 +- ui-tui/src/gatewayClient.ts | 44 +- ui-tui/src/main.js | 46 - ui-tui/src/main.tsx | 1764 ++++++++++----- ui-tui/src/theme.ts | 123 +- 11 files changed, 5529 insertions(+), 730 deletions(-) create mode 100644 ui-tui/.prettierrc create mode 100644 ui-tui/eslint.config.mjs delete mode 100644 ui-tui/src/main.js diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5a1c350698..616d63ef94 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -159,7 +159,7 @@ def _(req_id, params: dict) -> dict: status_callback=lambda text: _emit("status.update", sid, {"text": text}), clarify_callback=_make_clarify_cb(sid), ) - _sessions[sid] = {"agent": agent, "session_key": session_key} + _sessions[sid] = {"agent": agent, "session_key": session_key, "history": []} except Exception as e: return _err(req_id, 5000, f"agent init failed: {e}") @@ -180,16 +180,21 @@ def _(req_id, params: dict) -> dict: return _err(req_id, 4001, "session not found") agent = session["agent"] + history = session["history"] _emit("message.start", sid) def run(): try: result = agent.run_conversation( text, + conversation_history=list(history), stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), ) if isinstance(result, dict): + returned_msgs = result.get("messages") + if isinstance(returned_msgs, list): + session["history"] = returned_msgs final = result.get("final_response", "") status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" _emit("message.complete", sid, { @@ -248,8 +253,7 @@ 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)}) + return _ok(req_id, {"count": len(session.get("history", []))}) @method("session.undo") @@ -257,7 +261,7 @@ 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", []) + history = session.get("history", []) removed = 0 while history and history[-1].get("role") in ("assistant", "tool"): history.pop(); removed += 1 diff --git a/ui-tui/.prettierrc b/ui-tui/.prettierrc new file mode 100644 index 0000000000..12ec3ed7db --- /dev/null +++ b/ui-tui/.prettierrc @@ -0,0 +1,11 @@ +{ + "arrowParens": "avoid", + "bracketSpacing": true, + "endOfLine": "auto", + "printWidth": 120, + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs new file mode 100644 index 0000000000..6cb1c419a3 --- /dev/null +++ b/ui-tui/eslint.config.mjs @@ -0,0 +1,68 @@ +import js from '@eslint/js' +import typescriptEslint from '@typescript-eslint/eslint-plugin' +import typescriptParser from '@typescript-eslint/parser' +import perfectionist from 'eslint-plugin-perfectionist' +import reactPlugin from 'eslint-plugin-react' +import hooksPlugin from 'eslint-plugin-react-hooks' +import unusedImports from 'eslint-plugin-unused-imports' +import globals from 'globals' + +export default [ + js.configs.recommended, + { + files: ['**/*.{ts,tsx}'], + languageOptions: { + globals: { ...globals.node }, + parser: typescriptParser, + parserOptions: { + ecmaFeatures: { jsx: true }, + ecmaVersion: 'latest', + sourceType: 'module' + } + }, + plugins: { + '@typescript-eslint': typescriptEslint, + perfectionist, + react: reactPlugin, + 'react-hooks': hooksPlugin, + 'unused-imports': unusedImports + }, + rules: { + curly: ['error', 'all'], + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + '@typescript-eslint/no-unused-vars': 'off', + 'no-undef': 'off', + 'no-unused-vars': 'off', + 'padding-line-between-statements': [ + 1, + { blankLine: 'always', next: ['block-like', 'block', 'return', 'if', 'class', 'continue', 'debugger', 'break', 'multiline-const', 'multiline-let'], prev: '*' }, + { blankLine: 'always', next: '*', prev: ['case', 'default', 'multiline-const', 'multiline-let', 'multiline-block-like'] }, + { blankLine: 'never', next: ['block', 'block-like'], prev: ['case', 'default'] }, + { blankLine: 'always', next: ['block', 'block-like'], prev: ['block', 'block-like'] }, + { blankLine: 'always', next: ['empty'], prev: 'export' }, + { blankLine: 'never', next: 'iife', prev: ['block', 'block-like', 'empty'] } + ], + 'perfectionist/sort-exports': ['error', { order: 'asc', type: 'natural' }], + 'perfectionist/sort-imports': [ + 'error', + { + groups: ['side-effect', 'builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + order: 'asc', + type: 'natural' + } + ], + 'perfectionist/sort-jsx-props': ['error', { order: 'asc', type: 'natural' }], + 'perfectionist/sort-named-exports': ['error', { order: 'asc', type: 'natural' }], + 'perfectionist/sort-named-imports': ['error', { order: 'asc', type: 'natural' }], + 'react-hooks/exhaustive-deps': 'warn', + 'react-hooks/rules-of-hooks': 'error', + 'unused-imports/no-unused-imports': 'error' + }, + settings: { + react: { version: 'detect' } + } + }, + { + ignores: ['node_modules/', 'dist/', '*.config.*'] + } +] diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index a5b78523c3..e378fa2c64 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -13,8 +13,18 @@ "react": "^19.2.4" }, "devDependencies": { + "@eslint/js": "^9", "@types/node": "^25.5.0", "@types/react": "^19.2.14", + "@typescript-eslint/eslint-plugin": "^8", + "@typescript-eslint/parser": "^8", + "eslint": "^9", + "eslint-plugin-perfectionist": "^5", + "eslint-plugin-react": "^7", + "eslint-plugin-react-hooks": "^7", + "eslint-plugin-unused-imports": "^4", + "globals": "^16", + "prettier": "^3", "tsx": "^4.19.0", "typescript": "^5.7.0" } @@ -32,6 +42,266 @@ "node": ">=18" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.5.tgz", @@ -474,6 +744,338 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -488,12 +1090,285 @@ "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, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -533,6 +1408,161 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/auto-bind": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", @@ -545,6 +1575,173 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -628,6 +1825,40 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/convert-to-spaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", @@ -637,13 +1868,178 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "dev": true, + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -662,6 +2058,184 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-abstract": { + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/es-toolkit": { "version": "1.45.1", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", @@ -714,6 +2288,16 @@ "@esbuild/win32-x64": "0.27.5" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -723,6 +2307,506 @@ "node": ">=8" } }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.8.0.tgz", + "integrity": "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.58.0", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -738,6 +2822,67 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.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", @@ -750,6 +2895,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-tsconfig": { "version": "4.13.7", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", @@ -763,6 +2965,210 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -872,6 +3278,182 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "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", @@ -887,6 +3469,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-in-ci": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", @@ -902,6 +3517,389 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -911,6 +3909,190 @@ "node": ">=6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -926,6 +4108,87 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/patch-console": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", @@ -935,6 +4198,111 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -944,6 +4312,13 @@ "node": ">=0.10.0" } }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react-reconciler": { "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", @@ -959,6 +4334,84 @@ "react": "^19.2.0" } }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -985,12 +4438,228 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -1025,6 +4694,20 @@ "node": ">=10" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", @@ -1042,6 +4725,104 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/strip-ansi": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", @@ -1057,6 +4838,45 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", @@ -1081,6 +4901,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -1101,6 +4951,19 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-fest": { "version": "4.41.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", @@ -1113,6 +4976,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1127,6 +5068,25 @@ "node": ">=14.17" } }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -1134,6 +5094,152 @@ "dev": true, "license": "MIT" }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/widest-line": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", @@ -1165,6 +5271,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -1203,11 +5319,54 @@ } } }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "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" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/ui-tui/package.json b/ui-tui/package.json index 52c400d421..dd404863f7 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -7,7 +7,10 @@ "dev": "tsx --watch src/main.tsx", "start": "tsx src/main.tsx", "build": "tsc", - "test": "echo 'no tests yet'" + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "fmt": "prettier --write 'src/**/*.{ts,tsx}'", + "fix": "npm run lint:fix && npm run fmt" }, "dependencies": { "ink": "^6.8.0", @@ -15,8 +18,18 @@ "react": "^19.2.4" }, "devDependencies": { + "@eslint/js": "^9", "@types/node": "^25.5.0", "@types/react": "^19.2.14", + "@typescript-eslint/eslint-plugin": "^8", + "@typescript-eslint/parser": "^8", + "eslint": "^9", + "eslint-plugin-perfectionist": "^5", + "eslint-plugin-react": "^7", + "eslint-plugin-react-hooks": "^7", + "eslint-plugin-unused-imports": "^4", + "globals": "^16", + "prettier": "^3", "tsx": "^4.19.0", "typescript": "^5.7.0" } diff --git a/ui-tui/src/altScreen.tsx b/ui-tui/src/altScreen.tsx index f6c74888bd..34f88a6a99 100644 --- a/ui-tui/src/altScreen.tsx +++ b/ui-tui/src/altScreen.tsx @@ -1,5 +1,5 @@ -import { useEffect, type PropsWithChildren } from 'react' import { Box, useStdout } from 'ink' +import { type PropsWithChildren, useEffect } from 'react' const ENTER = '\x1b[?1049h\x1b[2J\x1b[H' const LEAVE = '\x1b[?1049l' @@ -22,7 +22,7 @@ export function AltScreen({ children }: PropsWithChildren) { }, []) return ( - + {children} ) diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts index 3d52c91379..afc8a94dd0 100644 --- a/ui-tui/src/banner.ts +++ b/ui-tui/src/banner.ts @@ -8,7 +8,7 @@ const LOGO_ART = [ '███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ', '██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ', '██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ', - '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ', + '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ' ] const CADUCEUS_ART = [ @@ -26,18 +26,19 @@ 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 +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 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 index 95ae06b1c0..b104358bfe 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -1,7 +1,7 @@ -import { spawn, type ChildProcess } from 'node:child_process' -import { createInterface } from 'node:readline' +import { type ChildProcess, spawn } from 'node:child_process' import { EventEmitter } from 'node:events' import { resolve } from 'node:path' +import { createInterface } from 'node:readline' export interface GatewayEvent { type: string @@ -22,17 +22,20 @@ export class GatewayClient extends EventEmitter { 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 = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], { + cwd: root, + stdio: ['pipe', 'pipe', 'inherit'] }) - this.proc.on('exit', (code) => this.emit('exit', code)) + createInterface({ input: this.proc.stdout! }).on('line', raw => { + try { + this.dispatch(JSON.parse(raw)) + } catch { + /* malformed line */ + } + }) + + this.proc.on('exit', code => this.emit('exit', code)) } private dispatch(msg: Record) { @@ -41,32 +44,33 @@ export class GatewayClient extends EventEmitter { if (p) { this.pending.delete(id!) - msg.error - ? p.reject(new Error((msg.error as any).message)) - : p.resolve(msg.result) + msg.error ? p.reject(new Error((msg.error as any).message)) : p.resolve(msg.result) + return } - if (msg.method === 'event') + 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', - ) + 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)) + if (this.pending.delete(id)) { reject(new Error(`timeout: ${method}`)) + } }, 30_000) }) } - kill() { this.proc?.kill() } + kill() { + this.proc?.kill() + } } diff --git a/ui-tui/src/main.js b/ui-tui/src/main.js deleted file mode 100644 index 892853bc95..0000000000 --- a/ui-tui/src/main.js +++ /dev/null @@ -1,46 +0,0 @@ -"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 index fa9d1bee41..c035b72ad9 100644 --- a/ui-tui/src/main.tsx +++ b/ui-tui/src/main.tsx @@ -1,112 +1,203 @@ 'use strict' -import { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { render, Box, Text, useApp, useStdout, useInput } from 'ink' +import { Box, render, Text, useApp, useInput, useStdout } from 'ink' import TextInput from 'ink-text-input' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AltScreen } from './altScreen.js' +import { caduceus, logo, LOGO_WIDTH } from './banner.js' 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' +// ── Types ─────────────────────────────────────────────────────────── 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 } +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 +} + +// ── Constants ─────────────────────────────────────────────────────── const ZERO: Usage = { input: 0, output: 0, total: 0, calls: 0 } const MAX_CTX = 128_000 +const LONG_MSG = 300 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'], + ['/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'], + ['/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'], + ['Ctrl+C', 'interrupt / clear / exit'], + ['Ctrl+D', 'exit'], + ['Ctrl+L', 'clear screen'], + ['↑/↓', 'queue edit (if queued) / input history'], + ['PgUp/PgDn', 'scroll messages'], + ['Esc', 'clear input'], ['\\+Enter', 'multi-line continuation'], - ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'], + ['!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?"', + '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', + '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', + 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 ROLE: Record { glyph: string; prefix: string; body: string }> = { + user: t => ({ glyph: t.brand.prompt, prefix: t.color.label, body: t.color.label }), + assistant: t => ({ glyph: t.brand.tool, prefix: t.color.bronze, body: t.color.cornsilk }), + system: t => ({ glyph: '!', prefix: t.color.error, body: t.color.error }), + tool: t => ({ glyph: '⚡', prefix: t.color.dim, body: t.color.dim }) } +// ── Pure helpers ──────────────────────────────────────────────────── + const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! -const fmtK = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}` +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) + return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one +} + +const userDisplay = (text: string): string => { + if (text.length <= LONG_MSG) { + return text + } + + const first = text.split('\n')[0]?.trim() ?? '' + const words = first.split(/\s+/).filter(Boolean) + const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) + + return `${prefix || '(message)'} [long message]` +} + +const INTERPOLATION_RE = /\{!(.+?)\}/g +const hasInterpolation = (s: string) => INTERPOLATION_RE.test(s) + +const PLACEHOLDER = pick(PLACEHOLDERS) // ── Components ────────────────────────────────────────────────────── function ArtLines({ lines }: { lines: [string, string][] }) { - return <>{lines.map(([c, text], i) => {text})} + return ( + <> + {lines.map(([c, text], i) => ( + + {text} + + ))} + + ) } function Banner({ t }: { t: Theme }) { @@ -114,12 +205,14 @@ function Banner({ t }: { t: Theme }) { return ( - {cols >= LOGO_WIDTH - ? - : {t.brand.icon} NOUS HERMES} - + {cols >= LOGO_WIDTH ? ( + + ) : ( + + {t.brand.icon} NOUS HERMES + + )} - {t.brand.icon} Nous Research · Messenger of the Digital Gods @@ -128,22 +221,27 @@ function Banner({ t }: { t: Theme }) { ) } -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 strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) + + const truncLine = (pfx: string, items: string[]) => { + let line = '' + + for (const item of items.sort()) { + const next = line ? `${line}, ${item}` : item + + if (pfx.length + next.length > w) { + return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` + } + + line = next + } + + return line + } const section = (title: string, data: Record, max = 8) => { const entries = Object.entries(data).sort() @@ -152,50 +250,45 @@ function SessionPanel({ t, info }: { t: Theme; info: SessionInfo }) { return ( - Available {title} - + + Available {title} + {shown.map(([k, vs]) => ( {strip(k)}: - {truncLine(strip(k) + ': ', vs, w)} + {truncLine(strip(k) + ': ', vs)} ))} - - {overflow > 0 && ( - (and {overflow} more…) - )} + {overflow > 0 && (and {overflow} more…)} ) } return ( - - + {wide && ( - + Nous Research )} - - {t.brand.icon} {t.brand.name} - + + {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 + {flat(info.tools).length} tools{' · '} + {flat(info.skills).length} skills + {' · '} + /help for commands - {info.model.split('/').pop()} - {' · '}Ctrl+C to interrupt + {' · '}Ctrl+C to interrupt @@ -204,13 +297,18 @@ function SessionPanel({ t, info }: { t: Theme; info: SessionInfo }) { function CommandPalette({ t, filter }: { t: Theme; filter: string }) { const m = COMMANDS.filter(([cmd]) => cmd.startsWith(filter)) - if (!m.length) return null + + if (!m.length) { + return null + } return ( {m.map(([cmd, desc]) => ( - {cmd} + + {cmd} + — {desc} ))} @@ -220,77 +318,101 @@ function CommandPalette({ t, filter }: { t: Theme; filter: string }) { 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)) + 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}…} - + {tools.length ? ( + tools.map(tool => ( + + {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}… + + )) + ) : ( + + {SPINNER[frame]} {face} {verb}… + + )} {reasoning && ( - {' 💭 '}{reasoning.slice(-120).replace(/\n/g, ' ')} + {' 💭 '} + {reasoning.slice(-120).replace(/\n/g, ' ')} )} ) } - -// ── Interactive Prompts ───────────────────────────────────────────── +// ── Interactive prompts ───────────────────────────────────────────── function ClarifyPrompt({ t, req, onAnswer }: { t: Theme; req: ClarifyReq; onAnswer: (s: string) => void }) { - const [sel, setSel] = useState(0) + 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]!) + 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 (n >= 1 && n <= choices.length) { + onAnswer(choices[n - 1]!) + } }) - if (typing || !choices.length) + if (typing || !choices.length) { return ( - ❓ {req.question} + + ❓ {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)')} + + ❓ {req.question} + + {[...choices, 'Other (type your answer)'].map((c, i) => ( + + {sel === i ? '▸ ' : ' '} + + {i + 1}. {c} + + + ))} ↑/↓ select · Enter confirm · 1-{choices.length} quick pick ) @@ -299,27 +421,50 @@ function ClarifyPrompt({ t, req, onAnswer }: { t: Theme; req: ClarifyReq; onAnsw 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 + const labels = { once: 'Allow once', session: 'Allow this session', always: 'Always allow', deny: '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') + 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} + + ⚠️ 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'} + [{o[0]}] {labels[o]} ))} @@ -328,7 +473,6 @@ function ApprovalPrompt({ t, req, onChoice }: { t: Theme; req: ApprovalReq; onCh ) } - // ── Markdown ──────────────────────────────────────────────────────── function Md({ t, text, compact }: { t: Theme; text: string; compact?: boolean }) { @@ -340,31 +484,75 @@ function Md({ t, text, compact }: { t: Theme; text: string; compact?: boolean }) const line = lines[i]! const k = nodes.length - if (compact && !line.trim()) { i++; continue } + 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++) + + for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) { block.push(lines[i]!) + } + i++ nodes.push( - + {lang && {'─ ' + lang}} - {block.map((l, j) => {l})} - , + {block.map((l, j) => ( + + {l} + + ))} + ) + continue } const hm = line.match(/^#{1,3}\s+(.*)/) - if (hm) { nodes.push({hm[1]}); i++; continue } + + if (hm) { + nodes.push( + + {hm[1]} + + ) + i++ + + continue + } const bm = line.match(/^\s*[-*]\s(.*)/) - if (bm) { nodes.push(); i++; continue } + + if (bm) { + nodes.push( + + + + + ) + i++ + + continue + } const nm = line.match(/^\s*(\d+)\.\s(.*)/) - if (nm) { nodes.push( {nm[1]}. ); i++; continue } + + if (nm) { + nodes.push( + + {nm[1]}. + + + ) + i++ + + continue + } nodes.push() i++ @@ -376,162 +564,322 @@ function Md({ t, text, compact }: { t: Theme; text: string; compact?: boolean }) 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 + + let last = 0, + m: RegExpExecArray | null while ((m = re.exec(text)) !== null) { - if (m.index > last) - parts.push({text.slice(last, m.index)}) + if (m.index > last) { + parts.push( + + {text.slice(last, m.index)} + + ) + } if (m[2] && m[3]) { - parts.push({m[2]}) + parts.push( + + {m[2]} + + ) } else if (m[4]) { - parts.push({m[4]}) + parts.push( + + {m[4]} + + ) } else if (m[5]) { - parts.push({m[5]}) + parts.push( + + {m[5]} + + ) } else if (m[6]) { - parts.push({m[6]}) + parts.push( + + {m[6]} + + ) } else if (m[7]) { - parts.push({m[7]}) + parts.push( + + {m[7]} + + ) } last = m.index + m[0].length } - if (last < text.length) - parts.push({text.slice(last)}) + 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' ? '⚡' : '!' + const { glyph, prefix, body } = ROLE[msg.role](t) + + const content = (() => { + if (msg.role === 'assistant') { + return + } + + if (msg.role === 'user' && msg.text.length > LONG_MSG) { + const d = userDisplay(msg.text) + const [head, ...rest] = d.split('[long message]') + + return ( + + {head} + + [long message] + + {rest.join('')} + + ) + } + + return {msg.text} + })() return ( - {glyph} - - {msg.role === 'assistant' - ? - : {msg.text}} + + + {glyph}{' '} + + + {content} ) } - // ── App ───────────────────────────────────────────────────────────── function App({ gw }: { gw: GatewayClient }) { - const { exit } = useApp() + const { exit } = useApp() const { stdout } = useStdout() - const cols = stdout?.columns ?? 80 + const cols = stdout?.columns ?? 80 + const rows = stdout?.rows ?? 24 - const [input, setInput] = useState('') + // ── State ───────────────────────────────────────────────────────── + + 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 [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 [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 [historyIdx, setHistoryIdx] = useState(null) const [scrollOffset, setScrollOffset] = useState(0) + const [queuedDisplay, setQueuedDisplay] = useState([]) - const buf = useRef('') + 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 queueEditRef = useRef(null) + const lastEmptyAt = useRef(0) + const empty = !messages.length + const blocked = !!(clarify || approval) + + // ── Queue / history helpers ─────────────────────────────────────── + + const syncQueue = () => setQueuedDisplay([...queueRef.current]) const setQueueEdit = (idx: number | null) => { - queueEditIdxRef.current = idx + queueEditRef.current = idx setQueueEditIdx(idx) } const enqueue = (text: string) => { queueRef.current.push(text) - setQueuedDisplay([...queueRef.current]) + syncQueue() + } + + const dequeue = () => { + const [h, ...rest] = queueRef.current + queueRef.current = rest + syncQueue() + + return h + } + + const replaceQ = (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() } const pushHistory = (text: string) => { const t = text.trim() - if (!t) return - const h = historyRef.current - if (h.at(-1) !== t) h.push(t) + + if (t && historyRef.current.at(-1) !== t) { + historyRef.current.push(t) + } } - const replaceQueued = (idx: number, text: string) => { - if (idx < 0 || idx >= queueRef.current.length) return - queueRef.current[idx] = text - setQueuedDisplay([...queueRef.current]) - } + // ── Derived ─────────────────────────────────────────────────────── - const removeQueued = (idx: number) => { - if (idx < 0 || idx >= queueRef.current.length) return - queueRef.current = queueRef.current.filter((_, i) => i !== idx) - setQueuedDisplay([...queueRef.current]) - } + useEffect(() => { + if (stickyRef.current) { + setScrollOffset(0) + } + }, [messages.length]) - const dequeue = () => { - const [next, ...rest] = queueRef.current - queueRef.current = rest - setQueuedDisplay([...rest]) - return next - } + const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2) - useEffect(() => { if (stickyRef.current) setScrollOffset(0) }, [messages.length]) + const viewport = useMemo(() => { + if (!messages.length) { + return { start: 0, end: 0, above: 0 } + } - 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 + + let budget = msgBudget, + 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 + const m = messages[i]! + const margin = m.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 + budget -= margin + estimateRows(m.role === 'user' ? userDisplay(m.text) : m.text, w) + + if (budget >= 0) { + start = i + } } + + if (start === end && end > 0) { + start = end - 1 + } + return { start, end, above: start } }, [messages, scrollOffset, msgBudget, cols]) + // ── Actions ─────────────────────────────────────────────────────── + + 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 scrollBot = () => { + setScrollOffset(0) + stickyRef.current = true + } + const scrollUp = (n: number) => { - setScrollOffset(prev => Math.min(Math.max(0, messages.length - 1), prev + n)) + setScrollOffset(p => Math.min(Math.max(0, messages.length - 1), p + 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) + const scrollDown = (n: number) => { + setScrollOffset(p => { + const v = Math.max(0, p - n) + + if (!v) { + stickyRef.current = true + } + + return v + }) + } + + const send = (text: string) => { + setLastUserMsg(text) + setMessages(p => [...p, { role: 'user', text }]) + scrollBot() + setStatus('thinking…') + setBusy(true) + buf.current = '' + gw.request('prompt.submit', { session_id: sid, text }).catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + setBusy(false) + }) + } + + const shellExec = (cmd: string) => { + setMessages(p => [...p, { role: 'user', text: `!${cmd}` }]) + setBusy(true) + setStatus('running…') + gw.request('shell.exec', { command: cmd }) + .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) + }) + } + + const interpolate = (text: string, then: (result: string) => void) => { + setStatus('interpolating…') + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + Promise.all( + matches.map(m => + gw + .request('shell.exec', { command: m[1]! }) + .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim()) + .catch(() => '(error)') + ) + ).then(results => { + let out = text + + for (let i = matches.length - 1; i >= 0; i--) { + out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) + } + + then(out) + }) + } // ── Hotkeys ─────────────────────────────────────────────────────── @@ -539,13 +887,24 @@ function App({ gw }: { gw: GatewayClient }) { if (blocked) { if (key.ctrl && ch === 'c' && approval) { gw.request('approval.respond', { session_id: sid, choice: 'deny' }).catch(() => {}) - setApproval(null); sys('denied') + setApproval(null) + sys('denied') } + return } - if (key.pageUp) { scrollUp(5); return } - if (key.pageDown) { scrollDown(5); return } + if (key.pageUp) { + scrollUp(5) + + return + } + + if (key.pageDown) { + scrollDown(5) + + return + } if (key.upArrow && !inputBuf.length) { if (queueRef.current.length) { @@ -554,16 +913,19 @@ function App({ gw }: { gw: GatewayClient }) { setQueueEdit(idx) setHistoryIdx(null) setInput(queueRef.current[idx] ?? '') - return + } else if (historyRef.current.length) { + const h = historyRef.current + 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] ?? '') } - 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 } @@ -574,459 +936,680 @@ function App({ gw }: { gw: GatewayClient }) { setQueueEdit(idx) setHistoryIdx(null) setInput(queueRef.current[idx] ?? '') - return + } else if (historyIdx !== null) { + const h = historyRef.current + const next = historyIdx + 1 + + if (next >= h.length) { + setHistoryIdx(null) + setInput(historyDraftRef.current) + } else { + setHistoryIdx(next) + setInput(h[next] ?? '') + } } - 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') + 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() + 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 + 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 + switch (ev.type) { + case 'gateway.ready': + if (p?.skin) { + setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) + } - 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 + setStatus('forging session…') + gw.request('session.create') + .then((r: any) => { + setSid(r.session_id) + setStatus('ready') + }) + .catch((e: Error) => setStatus(`error: ${e.message}`)) - case 'reasoning.delta': - if (p?.text) setReasoning(prev => prev + p.text) - break + break - case 'tool.generating': - if (p?.name) setStatus(`preparing ${p.name}…`) - break + case 'session.info': + setInfo(p as SessionInfo) - 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 + 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 'thinking.delta': + break - case 'tool.complete': - setTools(prev => prev.filter(t => t.id !== p.tool_id)) - break + case 'message.start': + setThinking(true) + setBusy(true) + setReasoning('') + setStatus('thinking…') - case 'clarify.request': - setClarify({ requestId: p.request_id, question: p.question, choices: p.choices }) - setStatus('waiting for input…') - break + break - case 'approval.request': - setApproval({ command: p.command, description: p.description }) - setStatus('approval needed') - break + case 'status.update': + if (p?.text) { + setStatus(p.text) + } - case 'message.delta': - if (!p?.text) break - buf.current += p.text - setThinking(false); setTools([]); setReasoning('') - setMessages(prev => upsert(prev, 'assistant', buf.current.trimStart())) - break + 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 + case 'reasoning.delta': + if (p?.text) { + setReasoning(prev => prev + p.text) + } - // 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 'tool.generating': + if (p?.name) { + setStatus(`preparing ${p.name}…`) + } + + break + + case 'tool.progress': + if (p?.preview) { + setMessages(prev => + prev.at(-1)?.role === 'tool' + ? [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] + : [...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 (queueEditRef.current !== null) { + break + } + + 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 } - break - case 'error': - sys(`error: ${p?.message}`) - idle(); setStatus('ready') - break - } - }, [gw, sys]) + case 'error': + sys(`error: ${p?.message}`) + idle() + setStatus('ready') + + break + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [gw, sys] + ) useEffect(() => { gw.on('event', onEvent) - gw.on('exit', () => { setStatus('gateway exited'); exit() }) - return () => { gw.off('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(' ') + 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 + 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') + ) - case 'clear': setMessages([]); return true - case 'quit': case 'exit': die(); return true + 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') + case 'clear': + setMessages([]) + + return true + + case 'quit': // falls through + + 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 }) - .catch((e: Error) => setStatus(`error: ${e.message}`)) - return true + send(lastUserMsg) - 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 + 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': + setCompact(c => (arg ? true : !c)) + sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) - case 'compact': - if (arg) { setCompact(true); sys(`compact on, focus: ${arg}`) } - else { setCompact(c => !c); sys(`compact ${compact ? 'off' : 'on'}`) } - return true + 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 'compress': + if (!sid) { + return true + } - case 'cost': case 'usage': - sys(`in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}`) - return true + gw.request('session.compress', { session_id: sid }) + .then((r: any) => { + sys('context compressed') - 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 + if (r.usage) { + setUsage(r.usage) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + + return true + + case 'cost': // falls through + + case 'usage': + sys( + `in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}` + ) + + return true + case 'copy': { + const all = messages.filter(m => m.role === 'assistant') + const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.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 bar = Math.round((pct / 100) * 30) + sys( + `context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(bar)}${'░'.repeat(30 - bar)}] ${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 } - - 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]) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [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 + const submit = useCallback( + (value: string) => { + // double-enter flushes queue head + if (!value.trim() && !inputBuf.length) { + const now = Date.now() + const dbl = now - lastEmptyAt.current < 450 + lastEmptyAt.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) + send(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + // multi-line continuation + if (value.endsWith('\\')) { + setInputBuf(prev => [...prev, value.slice(0, -1)]) + setInput('') + + return + } + + const full = [...inputBuf, value].join('\n') + setInputBuf([]) + setInput('') + setHistoryIdx(null) + historyDraftRef.current = '' + + if (!full.trim() || !sid) { + return + } + + // queue edit mode → replace, don't send + const editIdx = queueEditRef.current + + if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { + replaceQ(editIdx, full) + setQueueEdit(null) + + return + } + + if (editIdx !== null) { + setQueueEdit(null) + } + + pushHistory(full) + + // queue if busy (slash/shell bypass; interpolation resolves then queues) + if (busy && !full.startsWith('/') && !full.startsWith('!')) { + if (hasInterpolation(full)) { + interpolate(full, enqueue) - 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) }) - } + enqueue(full) + + return } - return - } - lastEmptySubmitAt.current = 0 - if (value.endsWith('\\')) { - setInputBuf(prev => [...prev, value.slice(0, -1)]) - setInput('') - return - } + if (full.startsWith('!')) { + shellExec(full.slice(1).trim()) - const full = [...inputBuf, value].join('\n') - setInputBuf([]) - if (!full.trim() || !sid) return - setInput('') - setHistoryIdx(null) - historyDraftRef.current = '' + return + } - const editIdx = queueEditIdxRef.current + if (full.startsWith('/') && slash(full)) { + return + } - // 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) + if (hasInterpolation(full)) { + setBusy(true) + interpolate(full, send) - // queue if busy (slash commands still run immediately) - if (busy && !full.startsWith('/') && !full.startsWith('!')) { - enqueue(full) - return - } + 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]) + send(full) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [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) + const statusColor = + status === 'ready' + ? theme.color.ok + : status.startsWith('error') + ? theme.color.error + : status === 'interrupted' + ? theme.color.warn + : theme.color.dim + + const qW = 3 + const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queuedDisplay.length - qW)) + const qEnd = Math.min(queuedDisplay.length, qStart + qW) return ( - + + {/* ── Header ──────────────────────────────────────────────── */} {empty ? ( <> - {info && } - - {!sid - ? ⚕ {status} - : - type / for commands - {' · '}! for shell - {' · '}Ctrl+C to interrupt - } + {info && } + {!sid ? ( + ⚕ {status} + ) : ( + + type / for commands + {' · '} + ! for shell + {' · '} + Ctrl+C to interrupt + + )} ) : ( - {theme.brand.icon} - {theme.brand.name} - + + {theme.brand.icon}{' '} + + + {theme.brand.name} + {info?.model ? ` · ${info.model.split('/').pop()}` : ''} - {' · '}{status} + {' · '} + {status} {busy && ' · Ctrl+C to stop'} - {usage.total > 0 && ( - {' · '}{fmtK(usage.input)}↑ {fmtK(usage.output)}↓ ({usage.calls} calls) + {' · '} + {fmtK(usage.input)}↑ {fmtK(usage.output)}↓ ({usage.calls} calls) )} )} + {/* ── Messages ────────────────────────────────────────────── */} + - {visibleSlice.above > 0 && ( - ↑ {visibleSlice.above} above · PgUp/PgDn to scroll + {viewport.above > 0 && ( + + ↑ {viewport.above} above · PgUp/PgDn to scroll + )} - {messages.slice(visibleSlice.start, visibleSlice.end).map((m, i) => { - const ri = visibleSlice.start + i + + {messages.slice(viewport.start, viewport.end).map((m, i) => { + const ri = viewport.start + i + return ( - 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0}> - + 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0} + > + ) })} + {scrollOffset > 0 && ( - ↓ {scrollOffset} below · PgDn or Enter to return + + ↓ {scrollOffset} below · PgDn or Enter to return + )} - {thinking && } + + {thinking && } + {/* ── Prompts / chrome ─────────────────────────────────────── */} + {clarify && ( - { - gw.request('clarify.respond', { request_id: clarify.requestId, answer }).catch(() => {}) - setMessages(p => [...p, { role: 'user', text: answer }]) - setClarify(null); setStatus('thinking…') - }} /> + { + gw.request('clarify.respond', { request_id: clarify.requestId, answer }).catch(() => {}) + setMessages(p => [...p, { role: 'user', text: answer }]) + setClarify(null) + setStatus('thinking…') + }} + req={clarify} + t={theme} + /> )} {approval && ( - { - gw.request('approval.respond', { session_id: sid, choice }).catch(() => {}) - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }} /> + { + gw.request('approval.respond', { session_id: sid, choice }).catch(() => {}) + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }} + req={approval} + t={theme} + /> )} - {!blocked && input.startsWith('/') && } + {!blocked && input.startsWith('/') && } {queuedDisplay.length > 0 && ( - + queued ({queuedDisplay.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} - {queueStart > 0 && ( - + {qStart > 0 && ( + + {' '} + … + )} - {queueShown.map((q, i) => { - const idx = queueStart + i - const active = queueEditIdx === idx + {queuedDisplay.slice(qStart, qEnd).map((q, i) => { + const idx = qStart + i, + active = queueEditIdx === idx + return ( - + {active ? '▸' : ' '} {idx + 1}. {compactPreview(q, Math.max(16, cols - 10))} ) })} - {queueEnd < queuedDisplay.length && ( - - {' '}…and {queuedDisplay.length - queueEnd} more + {qEnd < queuedDisplay.length && ( + + {' '}…and {queuedDisplay.length - qEnd} more )} @@ -1042,29 +1625,36 @@ function App({ gw }: { gw: GatewayClient }) { )} - ) } +// ── Helpers ────────────────────────────────────────────────────────── function upsert(prev: Msg[], role: Role, text: string): Msg[] { - return prev.at(-1)?.role === role - ? [...prev.slice(0, -1), { role, text }] - : [...prev, { role, text }] + return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] } +// ── Boot ──────────────────────────────────────────────────────────── if (!process.stdin.isTTY) { - console.log('hermes-tui: no TTY (run in a real terminal)') + console.log('hermes-tui: no TTY') process.exit(0) } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 0a212d4dfe..caa194f38e 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -1,30 +1,30 @@ export interface ThemeColors { - gold: string - amber: string - bronze: string + gold: string + amber: string + bronze: string cornsilk: string - dim: string + dim: string - label: string - ok: string - error: string - warn: string + label: string + ok: string + error: string + warn: string - statusBg: string - statusFg: string - statusGood: string - statusWarn: string - statusBad: string + statusBg: string + statusFg: string + statusGood: string + statusWarn: string + statusBad: string statusCritical: string } export interface ThemeBrand { - name: string - icon: string - prompt: string + name: string + icon: string + prompt: string welcome: string goodbye: string - tool: string + tool: string } export interface Theme { @@ -32,74 +32,69 @@ export interface Theme { brand: ThemeBrand } - export const DEFAULT_THEME: Theme = { color: { - gold: '#FFD700', - amber: '#FFBF00', - bronze: '#CD7F32', + gold: '#FFD700', + amber: '#FFBF00', + bronze: '#CD7F32', cornsilk: '#FFF8DC', - dim: '#B8860B', + dim: '#B8860B', - label: '#4dd0e1', - ok: '#4caf50', - error: '#ef5350', - warn: '#ffa726', + label: '#4dd0e1', + ok: '#4caf50', + error: '#ef5350', + warn: '#ffa726', - statusBg: '#1a1a2e', - statusFg: '#C0C0C0', - statusGood: '#8FBC8F', - statusWarn: '#FFD700', - statusBad: '#FF8C00', - statusCritical: '#FF6B6B', + statusBg: '#1a1a2e', + statusFg: '#C0C0C0', + statusGood: '#8FBC8F', + statusWarn: '#FFD700', + statusBad: '#FF8C00', + statusCritical: '#FF6B6B' }, brand: { - name: 'Hermes Agent', - icon: '⚕', - prompt: '❯', + name: 'Hermes Agent', + icon: '⚕', + prompt: '❯', welcome: 'Type your message or /help for commands.', goodbye: 'Goodbye! ⚕', - tool: '┊', - }, + tool: '┊' + } } - -export function fromSkin( - colors: Record, - branding: Record, -): Theme { +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, + 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, + 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, + 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, - }, + 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 + } } } From bbba9ed4f20a9df605ab5c9e6fa63cb8004c1129 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Apr 2026 20:39:52 -0500 Subject: [PATCH 003/157] feat: split apart main.tsx --- ui-tui/.gitignore | 2 + ui-tui/package.json | 4 +- ui-tui/src/app.tsx | 942 ++++++++++++ ui-tui/src/components/branding.tsx | 113 ++ ui-tui/src/components/commandPalette.tsx | 25 + ui-tui/src/components/markdown.tsx | 152 ++ ui-tui/src/components/messageLine.tsx | 46 + ui-tui/src/components/prompts.tsx | 127 ++ ui-tui/src/components/queuedMessages.tsx | 53 + ui-tui/src/components/thinking.tsx | 41 + ui-tui/src/constants.ts | 115 ++ ui-tui/src/entry.tsx | 15 + ui-tui/src/lib/messages.ts | 5 + ui-tui/src/lib/text.ts | 34 + ui-tui/src/main.tsx | 1654 +--------------------- ui-tui/src/types.ts | 35 + 16 files changed, 1710 insertions(+), 1653 deletions(-) create mode 100644 ui-tui/.gitignore create mode 100644 ui-tui/src/app.tsx create mode 100644 ui-tui/src/components/branding.tsx create mode 100644 ui-tui/src/components/commandPalette.tsx create mode 100644 ui-tui/src/components/markdown.tsx create mode 100644 ui-tui/src/components/messageLine.tsx create mode 100644 ui-tui/src/components/prompts.tsx create mode 100644 ui-tui/src/components/queuedMessages.tsx create mode 100644 ui-tui/src/components/thinking.tsx create mode 100644 ui-tui/src/constants.ts create mode 100644 ui-tui/src/entry.tsx create mode 100644 ui-tui/src/lib/messages.ts create mode 100644 ui-tui/src/lib/text.ts create mode 100644 ui-tui/src/types.ts diff --git a/ui-tui/.gitignore b/ui-tui/.gitignore new file mode 100644 index 0000000000..1eae0cf670 --- /dev/null +++ b/ui-tui/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/ui-tui/package.json b/ui-tui/package.json index dd404863f7..2ea39f685b 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx --watch src/main.tsx", - "start": "tsx src/main.tsx", + "dev": "tsx --watch src/entry.tsx", + "start": "tsx src/entry.tsx", "build": "tsc", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx new file mode 100644 index 0000000000..9623d10add --- /dev/null +++ b/ui-tui/src/app.tsx @@ -0,0 +1,942 @@ +import { Box, Text, useApp, useInput, useStdout } from 'ink' +import TextInput from 'ink-text-input' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { AltScreen } from './altScreen.js' +import { Banner, SessionPanel } from './components/branding.js' +import { CommandPalette } from './components/commandPalette.js' +import { MessageLine } from './components/messageLine.js' +import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' +import { QueuedMessages } from './components/queuedMessages.js' +import { Thinking } from './components/thinking.js' +import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import type { GatewayClient } from './gatewayClient.js' +import { type GatewayEvent } from './gatewayClient.js' +import { upsert } from './lib/messages.js' +import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' +import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' +import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SessionInfo, Usage } from './types.js' + +const PLACEHOLDER = pick(PLACEHOLDERS) + +export function App({ gw }: { gw: GatewayClient }) { + const { exit } = useApp() + const { stdout } = useStdout() + const cols = stdout?.columns ?? 80 + const rows = stdout?.rows ?? 24 + + 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 [historyIdx, setHistoryIdx] = useState(null) + const [scrollOffset, setScrollOffset] = useState(0) + const [queuedDisplay, setQueuedDisplay] = useState([]) + + const buf = useRef('') + const stickyRef = useRef(true) + const queueRef = useRef([]) + const historyRef = useRef([]) + const historyDraftRef = useRef('') + const queueEditRef = useRef(null) + const lastEmptyAt = useRef(0) + + const empty = !messages.length + const blocked = !!(clarify || approval) + + const syncQueue = () => setQueuedDisplay([...queueRef.current]) + + const setQueueEdit = (idx: number | null) => { + queueEditRef.current = idx + setQueueEditIdx(idx) + } + + const enqueue = (text: string) => { + queueRef.current.push(text) + syncQueue() + } + + const dequeue = () => { + const [head, ...rest] = queueRef.current + queueRef.current = rest + syncQueue() + + return head + } + + const replaceQ = (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() + } + + const pushHistory = (text: string) => { + const trimmed = text.trim() + + if (trimmed && historyRef.current.at(-1) !== trimmed) { + historyRef.current.push(trimmed) + } + } + + useEffect(() => { + if (stickyRef.current) { + setScrollOffset(0) + } + }, [messages.length]) + + const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2) + + const viewport = useMemo(() => { + if (!messages.length) { + return { above: 0, end: 0, start: 0 } + } + + const end = Math.max(0, messages.length - scrollOffset) + const width = Math.max(20, cols - 5) + + let budget = msgBudget + let start = end + + for (let i = end - 1; i >= 0 && budget > 0; i--) { + const msg = messages[i]! + const margin = msg.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 + budget -= margin + estimateRows(msg.role === 'user' ? userDisplay(msg.text) : msg.text, width) + + if (budget >= 0) { + start = i + } + } + + if (start === end && end > 0) { + start = end - 1 + } + + return { above: start, end, start } + }, [cols, messages, msgBudget, scrollOffset]) + + const sys = useCallback((text: string) => setMessages(prev => [...prev, { 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 scrollBot = () => { + setScrollOffset(0) + stickyRef.current = true + } + + 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 v = Math.max(0, prev - n) + + if (!v) { + stickyRef.current = true + } + + return v + }) + } + + const send = (text: string) => { + setLastUserMsg(text) + setMessages(prev => [...prev, { role: 'user', text }]) + scrollBot() + setStatus('thinking…') + setBusy(true) + buf.current = '' + gw.request('prompt.submit', { session_id: sid, text }).catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + setBusy(false) + }) + } + + const shellExec = (cmd: string) => { + setMessages(prev => [...prev, { role: 'user', text: `!${cmd}` }]) + setBusy(true) + setStatus('running…') + gw.request('shell.exec', { command: cmd }) + .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) + }) + } + + const interpolate = (text: string, then: (result: string) => void) => { + setStatus('interpolating…') + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + Promise.all( + matches.map(match => + gw + .request('shell.exec', { command: match[1]! }) + .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim()) + .catch(() => '(error)') + ) + ).then(results => { + let out = text + + for (let i = matches.length - 1; i >= 0; i--) { + out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) + } + + then(out) + }) + } + + useInput((ch, key) => { + if (blocked) { + if (key.ctrl && ch === 'c' && approval) { + gw.request('approval.respond', { choice: 'deny', session_id: sid }).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] ?? '') + } else if (historyRef.current.length) { + const hist = historyRef.current + const idx = historyIdx === null ? hist.length - 1 : Math.max(0, historyIdx - 1) + + if (historyIdx === null) { + historyDraftRef.current = input + } + + setHistoryIdx(idx) + setQueueEdit(null) + setInput(hist[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] ?? '') + } else if (historyIdx !== null) { + const hist = historyRef.current + const next = historyIdx + 1 + + if (next >= hist.length) { + setHistoryIdx(null) + setInput(historyDraftRef.current) + } else { + setHistoryIdx(next) + setInput(hist[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() + } + }) + + 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 => + prev.at(-1)?.role === 'tool' + ? [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] + : [...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({ choices: p.choices, question: p.question, requestId: p.request_id }) + 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 (queueEditRef.current !== null) { + break + } + + 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 + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [gw, sys] + ) + + useEffect(() => { + gw.on('event', onEvent) + gw.on('exit', () => { + setStatus('gateway exited') + exit() + }) + + return () => { + gw.off('event', onEvent) + } + }, [exit, gw, onEvent]) + + 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': // falls through + + 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(prev => { + const q = [...prev] + + 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(prev => { + const q = [...prev] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + return q + }) + send(lastUserMsg) + + return true + + case 'compact': + setCompact(c => (arg ? true : !c)) + sys(arg ? `compact on, focus: ${arg}` : `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': // falls through + + case 'usage': + sys( + `in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}` + ) + + return true + case 'copy': { + const all = messages.filter(m => m.role === 'assistant') + const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.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 bar = Math.round((pct / 100) * 30) + const icon = pct < 50 ? '✓' : pct < 80 ? '⚠' : '✗' + sys( + `context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(bar)}${'░'.repeat(30 - bar)}] ${icon}` + ) + + 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 + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [compact, gw, info, lastUserMsg, messages, sid, status, sys, usage] + ) + + const submit = useCallback( + (value: string) => { + if (!value.trim() && !inputBuf.length) { + const now = Date.now() + const dbl = now - lastEmptyAt.current < 450 + lastEmptyAt.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) + send(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + if (value.endsWith('\\')) { + setInputBuf(prev => [...prev, value.slice(0, -1)]) + setInput('') + + return + } + + const full = [...inputBuf, value].join('\n') + setInputBuf([]) + setInput('') + setHistoryIdx(null) + historyDraftRef.current = '' + + if (!full.trim() || !sid) { + return + } + + const editIdx = queueEditRef.current + + if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { + replaceQ(editIdx, full) + setQueueEdit(null) + + return + } + + if (editIdx !== null) { + setQueueEdit(null) + } + + pushHistory(full) + + if (busy && !full.startsWith('/') && !full.startsWith('!')) { + if (hasInterpolation(full)) { + interpolate(full, enqueue) + + return + } + + enqueue(full) + + return + } + + if (full.startsWith('!')) { + shellExec(full.slice(1).trim()) + + return + } + + if (full.startsWith('/') && slash(full)) { + return + } + + if (hasInterpolation(full)) { + setBusy(true) + interpolate(full, send) + + return + } + + send(full) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [busy, gw, inputBuf, sid, slash, sys] + ) + + const statusColor = + status === 'ready' + ? theme.color.ok + : status.startsWith('error') + ? theme.color.error + : status === 'interrupted' + ? theme.color.warn + : theme.color.dim + + 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) + + )} + + )} + + + {viewport.above > 0 && ( + + ↑ {viewport.above} above · PgUp/PgDn to scroll + + )} + + {messages.slice(viewport.start, viewport.end).map((m, i) => { + const ri = viewport.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', { answer, request_id: clarify.requestId }).catch(() => {}) + setMessages(prev => [...prev, { role: 'user', text: answer }]) + setClarify(null) + setStatus('thinking…') + }} + req={clarify} + t={theme} + /> + )} + + {approval && ( + { + gw.request('approval.respond', { choice, session_id: sid }).catch(() => {}) + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }} + req={approval} + t={theme} + /> + )} + + {!blocked && input.startsWith('/') && } + + + + {'─'.repeat(cols - 2)} + + {!blocked && ( + + + + {inputBuf.length ? '… ' : `${theme.brand.prompt} `} + + + + + )} + + + ) +} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx new file mode 100644 index 0000000000..ba46d0147d --- /dev/null +++ b/ui-tui/src/components/branding.tsx @@ -0,0 +1,113 @@ +import { Box, Text, useStdout } from 'ink' + +import { caduceus, logo, LOGO_WIDTH } from '../banner.js' +import { flat } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { SessionInfo } from '../types.js' + +export function ArtLines({ lines }: { lines: [string, string][] }) { + return ( + <> + {lines.map(([c, text], i) => ( + + {text} + + ))} + + ) +} + +export 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 + + + ) +} + +export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { + 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 truncLine = (pfx: string, items: string[]) => { + let line = '' + + for (const item of items.sort()) { + const next = line ? `${line}, ${item}` : item + + if (pfx.length + next.length > w) { + return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` + } + + line = next + } + + return line + } + + 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)} + + ))} + {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 + + + + ) +} diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx new file mode 100644 index 0000000000..be7f755d04 --- /dev/null +++ b/ui-tui/src/components/commandPalette.tsx @@ -0,0 +1,25 @@ +import { Box, Text } from 'ink' + +import { COMMANDS } from '../constants.js' +import type { Theme } from '../theme.js' + +export function CommandPalette({ filter, t }: { filter: string; t: Theme }) { + const matches = COMMANDS.filter(([cmd]) => cmd.startsWith(filter)) + + if (!matches.length) { + return null + } + + return ( + + {matches.map(([cmd, desc]) => ( + + + {cmd} + + — {desc} + + ))} + + ) +} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx new file mode 100644 index 0000000000..c0d71403bf --- /dev/null +++ b/ui-tui/src/components/markdown.tsx @@ -0,0 +1,152 @@ +import { Box, Text } from 'ink' +import type { ReactNode } from 'react' + +import type { Theme } from '../theme.js' + +function MdInline({ t, text }: { t: Theme; text: string }) { + const parts: ReactNode[] = [] + const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g + + let last = 0 + let match: RegExpExecArray | null + + while ((match = re.exec(text)) !== null) { + if (match.index > last) { + parts.push( + + {text.slice(last, match.index)} + + ) + } + + if (match[2] && match[3]) { + parts.push( + + {match[2]} + + ) + } else if (match[4]) { + parts.push( + + {match[4]} + + ) + } else if (match[5]) { + parts.push( + + {match[5]} + + ) + } else if (match[6]) { + parts.push( + + {match[6]} + + ) + } else if (match[7]) { + parts.push( + + {match[7]} + + ) + } + + last = match.index + match[0].length + } + + if (last < text.length) { + parts.push( + + {text.slice(last)} + + ) + } + + return {parts.length ? parts : {text}} +} + +export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) { + const lines = text.split('\n') + const nodes: ReactNode[] = [] + let i = 0 + + while (i < lines.length) { + const line = lines[i]! + const key = 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 heading = line.match(/^#{1,3}\s+(.*)/) + + if (heading) { + nodes.push( + + {heading[1]} + + ) + i++ + + continue + } + + const bullet = line.match(/^\s*[-*]\s(.*)/) + + if (bullet) { + nodes.push( + + + + + ) + i++ + + continue + } + + const numbered = line.match(/^\s*(\d+)\.\s(.*)/) + + if (numbered) { + nodes.push( + + {numbered[1]}. + + + ) + i++ + + continue + } + + nodes.push() + i++ + } + + return {nodes} +} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx new file mode 100644 index 0000000000..36d86acc70 --- /dev/null +++ b/ui-tui/src/components/messageLine.tsx @@ -0,0 +1,46 @@ +import { Box, Text } from 'ink' + +import { LONG_MSG, ROLE } from '../constants.js' +import { userDisplay } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { Msg } from '../types.js' + +import { Md } from './markdown.js' + +export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; t: Theme }) { + const { body, glyph, prefix } = ROLE[msg.role](t) + + const content = (() => { + if (msg.role === 'assistant') { + return + } + + if (msg.role === 'user' && msg.text.length > LONG_MSG) { + const displayed = userDisplay(msg.text) + const [head, ...rest] = displayed.split('[long message]') + + return ( + + {head} + + [long message] + + {rest.join('')} + + ) + } + + return {msg.text} + })() + + return ( + + + + {glyph}{' '} + + + {content} + + ) +} diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx new file mode 100644 index 0000000000..cc9f743883 --- /dev/null +++ b/ui-tui/src/components/prompts.tsx @@ -0,0 +1,127 @@ +import { Box, Text, useInput } from 'ink' +import TextInput from 'ink-text-input' +import { useState } from 'react' + +import type { Theme } from '../theme.js' +import type { ApprovalReq, ClarifyReq } from '../types.js' + +export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) { + const [sel, setSel] = useState(3) + const opts = ['once', 'session', 'always', 'deny'] as const + const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } 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]}] {labels[o]} + + + ))} + ↑/↓ select · Enter confirm · o/s/a/d quick pick + + ) +} + +export function ClarifyPrompt({ onAnswer, req, t }: { onAnswer: (s: string) => void; req: ClarifyReq; t: Theme }) { + 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} + + + {'> '} + + + + ) + } + + return ( + + + ❓ {req.question} + + {[...choices, 'Other (type your answer)'].map((c, i) => ( + + {sel === i ? '▸ ' : ' '} + + {i + 1}. {c} + + + ))} + ↑/↓ select · Enter confirm · 1-{choices.length} quick pick + + ) +} diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx new file mode 100644 index 0000000000..07119fac36 --- /dev/null +++ b/ui-tui/src/components/queuedMessages.tsx @@ -0,0 +1,53 @@ +import { Box, Text } from 'ink' + +import { compactPreview } from '../lib/text.js' +import type { Theme } from '../theme.js' + +export function QueuedMessages({ + cols, + queueEditIdx, + queued, + t +}: { + cols: number + queueEditIdx: number | null + queued: string[] + t: Theme +}) { + if (!queued.length) { + return null + } + + const qWindow = 3 + const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queued.length - qWindow)) + const qEnd = Math.min(queued.length, qStart + qWindow) + + return ( + + + queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} + + {qStart > 0 && ( + + {' '} + … + + )} + {queued.slice(qStart, qEnd).map((item, i) => { + const idx = qStart + i + const active = queueEditIdx === idx + + return ( + + {active ? '▸' : ' '} {idx + 1}. {compactPreview(item, Math.max(16, cols - 10))} + + ) + })} + {qEnd < queued.length && ( + + {' '}…and {queued.length - qEnd} more + + )} + + ) +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx new file mode 100644 index 0000000000..f5d5cd3da2 --- /dev/null +++ b/ui-tui/src/components/thinking.tsx @@ -0,0 +1,41 @@ +import { Box, Text } from 'ink' +import { useEffect, useState } from 'react' + +import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js' +import { pick } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { ActiveTool } from '../types.js' + +export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; tools: ActiveTool[] }) { + 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, ' ')} + + )} + + ) +} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts new file mode 100644 index 0000000000..a8d2a99c12 --- /dev/null +++ b/ui-tui/src/constants.ts @@ -0,0 +1,115 @@ +import type { Theme } from './theme.js' +import type { Role, Usage } from './types.js' + +export 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'] +] + +export const FACES = [ + '(。•́︿•̀。)', + '(◔_◔)', + '(¬‿¬)', + '( •_•)>⌐■-■', + '(⌐■_■)', + '(´・_・`)', + '◉_◉', + '(°ロ°)', + '( ˘⌣˘)♡', + 'ヽ(>∀<☆)☆', + '٩(๑❛ᴗ❛๑)۶', + '(⊙_⊙)', + '(¬_¬)', + '( ͡° ͜ʖ ͡°)', + 'ಠ_ಠ' +] + +export 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'], + ['Esc', 'clear input'], + ['\\+Enter', 'multi-line continuation'], + ['!cmd', 'run shell command'], + ['{!cmd}', 'interpolate shell output inline'] +] + +export const INTERPOLATION_RE = /\{!(.+?)\}/g + +export const LONG_MSG = 300 +export const MAX_CTX = 128_000 + +export 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?"' +] + +export const ROLE: Record { body: string; glyph: string; prefix: string }> = { + assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }), + system: t => ({ body: t.color.error, glyph: '!', prefix: t.color.error }), + tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), + user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) +} + +export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + +export const TOOL_VERBS: Record = { + browser: '🌐 browsing', + clarify: '❓ asking', + create_file: '📝 creating', + delegate_task: '🤖 delegating', + delete_file: '🗑️ deleting', + execute_code: '⚡ executing', + image_generate: '🎨 generating', + list_files: '📂 listing', + memory: '🧠 remembering', + patch: '🩹 patching', + read_file: '📖 reading', + run_command: '⚙️ running', + search_code: '🔍 searching', + search_files: '🔍 searching', + terminal: '💻 terminal', + web_search: '🌐 searching', + write_file: '✏️ writing' +} + +export const VERBS = [ + 'pondering', + 'contemplating', + 'musing', + 'cogitating', + 'ruminating', + 'deliberating', + 'mulling', + 'reflecting', + 'processing', + 'reasoning', + 'analyzing', + 'computing', + 'synthesizing', + 'formulating', + 'brainstorming' +] + +export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx new file mode 100644 index 0000000000..ecb9e4a829 --- /dev/null +++ b/ui-tui/src/entry.tsx @@ -0,0 +1,15 @@ +'use strict' + +import { render } from 'ink' + +import { App } from './app.js' +import { GatewayClient } from './gatewayClient.js' + +if (!process.stdin.isTTY) { + console.log('hermes-tui: no TTY') + process.exit(0) +} + +const gw = new GatewayClient() +gw.start() +render(, { exitOnCtrlC: false }) diff --git a/ui-tui/src/lib/messages.ts b/ui-tui/src/lib/messages.ts new file mode 100644 index 0000000000..fc265abd4b --- /dev/null +++ b/ui-tui/src/lib/messages.ts @@ -0,0 +1,5 @@ +import type { Msg, Role } from '../types.js' + +export function upsert(prev: Msg[], role: Role, text: string): Msg[] { + return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] +} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts new file mode 100644 index 0000000000..68aa468c10 --- /dev/null +++ b/ui-tui/src/lib/text.ts @@ -0,0 +1,34 @@ +import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' + +export const compactPreview = (s: string, max: number) => { + const one = s.replace(/\s+/g, ' ').trim() + + return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one +} + +export 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) + +export const flat = (r: Record) => Object.values(r).flat() + +export const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`) + +export const hasInterpolation = (s: string) => { + INTERPOLATION_RE.lastIndex = 0 + + return INTERPOLATION_RE.test(s) +} + +export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! + +export const userDisplay = (text: string): string => { + if (text.length <= LONG_MSG) { + return text + } + + const first = text.split('\n')[0]?.trim() ?? '' + const words = first.split(/\s+/).filter(Boolean) + const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) + + return `${prefix || '(message)'} [long message]` +} diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx index c035b72ad9..ecb9e4a829 100644 --- a/ui-tui/src/main.tsx +++ b/ui-tui/src/main.tsx @@ -1,1657 +1,9 @@ 'use strict' -import { Box, render, Text, useApp, useInput, useStdout } from 'ink' -import TextInput from 'ink-text-input' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { render } from 'ink' -import { AltScreen } from './altScreen.js' -import { caduceus, logo, LOGO_WIDTH } from './banner.js' -import { GatewayClient, type GatewayEvent } from './gatewayClient.js' -import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' - -// ── Types ─────────────────────────────────────────────────────────── - -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 -} - -// ── Constants ─────────────────────────────────────────────────────── - -const ZERO: Usage = { input: 0, output: 0, total: 0, calls: 0 } -const MAX_CTX = 128_000 -const LONG_MSG = 300 - -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'], - ['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 { glyph: string; prefix: string; body: string }> = { - user: t => ({ glyph: t.brand.prompt, prefix: t.color.label, body: t.color.label }), - assistant: t => ({ glyph: t.brand.tool, prefix: t.color.bronze, body: t.color.cornsilk }), - system: t => ({ glyph: '!', prefix: t.color.error, body: t.color.error }), - tool: t => ({ glyph: '⚡', prefix: t.color.dim, body: t.color.dim }) -} - -// ── Pure helpers ──────────────────────────────────────────────────── - -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() - - return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one -} - -const userDisplay = (text: string): string => { - if (text.length <= LONG_MSG) { - return text - } - - const first = text.split('\n')[0]?.trim() ?? '' - const words = first.split(/\s+/).filter(Boolean) - const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) - - return `${prefix || '(message)'} [long message]` -} - -const INTERPOLATION_RE = /\{!(.+?)\}/g -const hasInterpolation = (s: string) => INTERPOLATION_RE.test(s) - -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 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 truncLine = (pfx: string, items: string[]) => { - let line = '' - - for (const item of items.sort()) { - const next = line ? `${line}, ${item}` : item - - if (pfx.length + next.length > w) { - return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` - } - - line = next - } - - return line - } - - 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)} - - ))} - {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} - - - {'> '} - - - - ) - } - - return ( - - - ❓ {req.question} - - {[...choices, 'Other (type your answer)'].map((c, i) => ( - - {sel === i ? '▸ ' : ' '} - - {i + 1}. {c} - - - ))} - ↑/↓ 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 - const labels = { once: 'Allow once', session: 'Allow this session', always: 'Always allow', deny: '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]}] {labels[o]} - - - ))} - ↑/↓ 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, - 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 { glyph, prefix, body } = ROLE[msg.role](t) - - const content = (() => { - if (msg.role === 'assistant') { - return - } - - if (msg.role === 'user' && msg.text.length > LONG_MSG) { - const d = userDisplay(msg.text) - const [head, ...rest] = d.split('[long message]') - - return ( - - {head} - - [long message] - - {rest.join('')} - - ) - } - - return {msg.text} - })() - - return ( - - - - {glyph}{' '} - - - {content} - - ) -} - -// ── App ───────────────────────────────────────────────────────────── - -function App({ gw }: { gw: GatewayClient }) { - const { exit } = useApp() - const { stdout } = useStdout() - const cols = stdout?.columns ?? 80 - const rows = stdout?.rows ?? 24 - - // ── State ───────────────────────────────────────────────────────── - - 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 [historyIdx, setHistoryIdx] = useState(null) - const [scrollOffset, setScrollOffset] = useState(0) - const [queuedDisplay, setQueuedDisplay] = useState([]) - - const buf = useRef('') - const stickyRef = useRef(true) - const queueRef = useRef([]) - const historyRef = useRef([]) - const historyDraftRef = useRef('') - const queueEditRef = useRef(null) - const lastEmptyAt = useRef(0) - - const empty = !messages.length - const blocked = !!(clarify || approval) - - // ── Queue / history helpers ─────────────────────────────────────── - - const syncQueue = () => setQueuedDisplay([...queueRef.current]) - - const setQueueEdit = (idx: number | null) => { - queueEditRef.current = idx - setQueueEditIdx(idx) - } - - const enqueue = (text: string) => { - queueRef.current.push(text) - syncQueue() - } - - const dequeue = () => { - const [h, ...rest] = queueRef.current - queueRef.current = rest - syncQueue() - - return h - } - - const replaceQ = (i: number, text: string) => { - queueRef.current[i] = text - syncQueue() - } - - const pushHistory = (text: string) => { - const t = text.trim() - - if (t && historyRef.current.at(-1) !== t) { - historyRef.current.push(t) - } - } - - // ── Derived ─────────────────────────────────────────────────────── - - useEffect(() => { - if (stickyRef.current) { - setScrollOffset(0) - } - }, [messages.length]) - - const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2) - - const viewport = 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, - start = end - - for (let i = end - 1; i >= 0 && budget > 0; i--) { - const m = messages[i]! - const margin = m.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 - budget -= margin + estimateRows(m.role === 'user' ? userDisplay(m.text) : m.text, w) - - if (budget >= 0) { - start = i - } - } - - if (start === end && end > 0) { - start = end - 1 - } - - return { start, end, above: start } - }, [messages, scrollOffset, msgBudget, cols]) - - // ── Actions ─────────────────────────────────────────────────────── - - 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 scrollBot = () => { - setScrollOffset(0) - stickyRef.current = true - } - - const scrollUp = (n: number) => { - setScrollOffset(p => Math.min(Math.max(0, messages.length - 1), p + n)) - stickyRef.current = false - } - - const scrollDown = (n: number) => { - setScrollOffset(p => { - const v = Math.max(0, p - n) - - if (!v) { - stickyRef.current = true - } - - return v - }) - } - - const send = (text: string) => { - setLastUserMsg(text) - setMessages(p => [...p, { role: 'user', text }]) - scrollBot() - setStatus('thinking…') - setBusy(true) - buf.current = '' - gw.request('prompt.submit', { session_id: sid, text }).catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - setBusy(false) - }) - } - - const shellExec = (cmd: string) => { - setMessages(p => [...p, { role: 'user', text: `!${cmd}` }]) - setBusy(true) - setStatus('running…') - gw.request('shell.exec', { command: cmd }) - .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) - }) - } - - const interpolate = (text: string, then: (result: string) => void) => { - setStatus('interpolating…') - const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] - Promise.all( - matches.map(m => - gw - .request('shell.exec', { command: m[1]! }) - .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim()) - .catch(() => '(error)') - ) - ).then(results => { - let out = text - - for (let i = matches.length - 1; i >= 0; i--) { - out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) - } - - then(out) - }) - } - - // ── 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] ?? '') - } else if (historyRef.current.length) { - const h = historyRef.current - 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] ?? '') - } else if (historyIdx !== null) { - 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 => - prev.at(-1)?.role === 'tool' - ? [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] - : [...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 (queueEditRef.current !== null) { - break - } - - 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 - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [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': // falls through - - 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 - }) - send(lastUserMsg) - - return true - - case 'compact': - setCompact(c => (arg ? true : !c)) - sys(arg ? `compact on, focus: ${arg}` : `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': // falls through - - case 'usage': - sys( - `in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}` - ) - - return true - case 'copy': { - const all = messages.filter(m => m.role === 'assistant') - const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.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 bar = Math.round((pct / 100) * 30) - sys( - `context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(bar)}${'░'.repeat(30 - bar)}] ${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 - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sid, status, sys, compact, info, usage, messages, lastUserMsg] - ) - - // ── Submit ──────────────────────────────────────────────────────── - - const submit = useCallback( - (value: string) => { - // double-enter flushes queue head - if (!value.trim() && !inputBuf.length) { - const now = Date.now() - const dbl = now - lastEmptyAt.current < 450 - lastEmptyAt.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) - send(next) - } - } - - return - } - - lastEmptyAt.current = 0 - - // multi-line continuation - if (value.endsWith('\\')) { - setInputBuf(prev => [...prev, value.slice(0, -1)]) - setInput('') - - return - } - - const full = [...inputBuf, value].join('\n') - setInputBuf([]) - setInput('') - setHistoryIdx(null) - historyDraftRef.current = '' - - if (!full.trim() || !sid) { - return - } - - // queue edit mode → replace, don't send - const editIdx = queueEditRef.current - - if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { - replaceQ(editIdx, full) - setQueueEdit(null) - - return - } - - if (editIdx !== null) { - setQueueEdit(null) - } - - pushHistory(full) - - // queue if busy (slash/shell bypass; interpolation resolves then queues) - if (busy && !full.startsWith('/') && !full.startsWith('!')) { - if (hasInterpolation(full)) { - interpolate(full, enqueue) - - return - } - - enqueue(full) - - return - } - - if (full.startsWith('!')) { - shellExec(full.slice(1).trim()) - - return - } - - if (full.startsWith('/') && slash(full)) { - return - } - - if (hasInterpolation(full)) { - setBusy(true) - interpolate(full, send) - - return - } - - send(full) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [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 qW = 3 - const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queuedDisplay.length - qW)) - const qEnd = Math.min(queuedDisplay.length, qStart + qW) - - return ( - - - {/* ── Header ──────────────────────────────────────────────── */} - - {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) - - )} - - )} - - {/* ── Messages ────────────────────────────────────────────── */} - - - {viewport.above > 0 && ( - - ↑ {viewport.above} above · PgUp/PgDn to scroll - - )} - - {messages.slice(viewport.start, viewport.end).map((m, i) => { - const ri = viewport.start + i - - return ( - 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0} - > - - - ) - })} - - {scrollOffset > 0 && ( - - ↓ {scrollOffset} below · PgDn or Enter to return - - )} - - {thinking && } - - - {/* ── Prompts / chrome ─────────────────────────────────────── */} - - {clarify && ( - { - gw.request('clarify.respond', { request_id: clarify.requestId, answer }).catch(() => {}) - setMessages(p => [...p, { role: 'user', text: answer }]) - setClarify(null) - setStatus('thinking…') - }} - req={clarify} - t={theme} - /> - )} - - {approval && ( - { - gw.request('approval.respond', { session_id: sid, choice }).catch(() => {}) - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }} - req={approval} - t={theme} - /> - )} - - {!blocked && input.startsWith('/') && } - - {queuedDisplay.length > 0 && ( - - - queued ({queuedDisplay.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} - - {qStart > 0 && ( - - {' '} - … - - )} - {queuedDisplay.slice(qStart, qEnd).map((q, i) => { - const idx = qStart + i, - active = queueEditIdx === idx - - return ( - - {active ? '▸' : ' '} {idx + 1}. {compactPreview(q, Math.max(16, cols - 10))} - - ) - })} - {qEnd < queuedDisplay.length && ( - - {' '}…and {queuedDisplay.length - qEnd} more - - )} - - )} - - {'─'.repeat(cols - 2)} - - {!blocked && ( - - - - {inputBuf.length ? '… ' : `${theme.brand.prompt} `} - - - - - )} - - - ) -} - -// ── Helpers ────────────────────────────────────────────────────────── - -function upsert(prev: Msg[], role: Role, text: string): Msg[] { - return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] -} - -// ── Boot ──────────────────────────────────────────────────────────── +import { App } from './app.js' +import { GatewayClient } from './gatewayClient.js' if (!process.stdin.isTTY) { console.log('hermes-tui: no TTY') diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts new file mode 100644 index 0000000000..4b4084eb4d --- /dev/null +++ b/ui-tui/src/types.ts @@ -0,0 +1,35 @@ +export interface ActiveTool { + id: string + name: string +} + +export interface ApprovalReq { + command: string + description: string +} + +export interface ClarifyReq { + choices: string[] | null + question: string + requestId: string +} + +export interface Msg { + role: Role + text: string +} + +export type Role = 'assistant' | 'system' | 'tool' | 'user' + +export interface SessionInfo { + model: string + skills: Record + tools: Record +} + +export interface Usage { + calls: number + input: number + output: number + total: number +} From f4bf57ff7a196076a4c9bf4f8d9ddd19d9084d43 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 2 Apr 2026 23:00:38 -0500 Subject: [PATCH 004/157] chore: uptick --- ui-tui/.gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/ui-tui/.gitignore b/ui-tui/.gitignore index 1eae0cf670..fc8abe6960 100644 --- a/ui-tui/.gitignore +++ b/ui-tui/.gitignore @@ -1,2 +1,3 @@ dist/ node_modules/ +src/*.js From 121899499237f4cb03903ed62d3efb551e1b0673 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Apr 2026 14:44:50 -0500 Subject: [PATCH 005/157] chore: uptick --- hermes_cli/skills_hub.py | 75 ++ tui_gateway/server.py | 922 ++++++++++++++++++------ ui-tui/src/app.tsx | 518 +++++++++++-- ui-tui/src/components/markdown.tsx | 46 ++ ui-tui/src/components/maskedPrompt.tsx | 29 + ui-tui/src/components/sessionPicker.tsx | 94 +++ ui-tui/src/components/thinking.tsx | 6 +- ui-tui/src/constants.ts | 41 +- ui-tui/src/lib/history.ts | 52 ++ ui-tui/src/types.ts | 10 + 10 files changed, 1513 insertions(+), 280 deletions(-) create mode 100644 ui-tui/src/components/maskedPrompt.tsx create mode 100644 ui-tui/src/components/sessionPicker.tsx create mode 100644 ui-tui/src/lib/history.ts diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 370b69ab0c..0ecb677fcf 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -496,6 +496,81 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: c.print() +def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> list[dict]: + """Paginated hub browse for programmatic callers (e.g. TUI gateway).""" + from tools.skills_hub import GitHubAuth, create_source_router + + page_size = max(1, min(page_size, 100)) + _TRUST_RANK = {"builtin": 3, "trusted": 2, "community": 1} + _PER_SOURCE_LIMIT = {"official": 100, "skills-sh": 100, "well-known": 25, "github": 100, "clawhub": 50, + "claude-marketplace": 50, "lobehub": 50} + auth = GitHubAuth() + sources = create_source_router(auth) + all_results: list = [] + for src in sources: + sid = src.source_id() + if source != "all" and sid != source and sid != "official": + continue + try: + limit = _PER_SOURCE_LIMIT.get(sid, 50) + all_results.extend(src.search("", limit=limit)) + except Exception: + continue + if not all_results: + return [] + seen: dict = {} + for r in all_results: + rank = _TRUST_RANK.get(r.trust_level, 0) + if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0): + seen[r.name] = r + deduped = list(seen.values()) + deduped.sort(key=lambda r: (-_TRUST_RANK.get(r.trust_level, 0), r.source != "official", r.name.lower())) + total = len(deduped) + total_pages = max(1, (total + page_size - 1) // page_size) + page = max(1, min(page, total_pages)) + start = (page - 1) * page_size + page_items = deduped[start : min(start + page_size, total)] + return [{"name": r.name, "description": r.description} for r in page_items] + + +def inspect_skill(identifier: str) -> Optional[dict]: + """Skill metadata (+ SKILL.md preview) for programmatic callers.""" + from tools.skills_hub import GitHubAuth, create_source_router + + class _Q: + def print(self, *a, **k): + pass + + c = _Q() + auth = GitHubAuth() + sources = create_source_router(auth) + ident = identifier + if "/" not in ident: + ident = _resolve_short_name(ident, sources, c) + if not ident: + return None + meta, bundle, _ = _resolve_source_meta_and_bundle(ident, sources) + if not meta: + return None + out: dict = { + "name": meta.name, + "description": meta.description, + "source": meta.source, + "identifier": meta.identifier, + "tags": list(meta.tags) if meta.tags else [], + } + if bundle and "SKILL.md" in bundle.files: + content = bundle.files["SKILL.md"] + if isinstance(content, bytes): + content = content.decode("utf-8", errors="replace") + lines = content.split("\n") + preview = "\n".join(lines[:50]) + if len(lines) > 50: + preview += f"\n\n... ({len(lines) - 50} more lines)" + out["skill_md_preview"] = preview + return out + + def do_list(source_filter: str = "all", console: Optional[Console] = None) -> None: """List installed skills, distinguishing hub, builtin, and local skills.""" from tools.skills_hub import HubLockFile, ensure_hub_dirs diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 616d63ef94..c8262e639f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -13,27 +13,36 @@ _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] = {} +_methods: dict[str, callable] = {} +_pending: dict[str, threading.Event] = {} +_answers: dict[str, str] = {} +_db = None -# ── Wire ───────────────────────────────────────────────────────────── +# ── Plumbing ────────────────────────────────────────────────────────── -def _emit(event_type: str, sid: str, payload: dict | None = None): - params = {"type": event_type, "session_id": sid} +def _get_db(): + global _db + if _db is None: + from hermes_state import SessionDB + _db = SessionDB() + return _db + + +def _emit(event: str, sid: str, payload: dict | None = None): + params = {"type": event, "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 _ok(rid, result: dict) -> dict: + return {"jsonrpc": "2.0", "id": rid, "result": result} -def _err(req_id, code: int, msg: str) -> dict: - return {"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": msg}} +def _err(rid, code: int, msg: str) -> dict: + return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": msg}} def method(name: str): @@ -50,18 +59,56 @@ def handle_request(req: dict) -> dict | None: return fn(req.get("id"), req.get("params", {})) -# ── Helpers ────────────────────────────────────────────────────────── +def _sess(params, rid): + s = _sessions.get(params.get("session_id", "")) + return (s, None) if s else (None, _err(rid, 4001, "session not found")) + + +# ── Config I/O ──────────────────────────────────────────────────────── + +def _load_cfg() -> dict: + try: + import yaml + p = _hermes_home / "config.yaml" + if p.exists(): + with open(p) as f: + return yaml.safe_load(f) or {} + except Exception: + pass + return {} + + +def _save_cfg(cfg: dict): + import yaml + with open(_hermes_home / "config.yaml", "w") as f: + yaml.safe_dump(cfg, f) + + +# ── Blocking prompt factory ────────────────────────────────────────── + +def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: + rid = uuid.uuid4().hex[:8] + ev = threading.Event() + _pending[rid] = ev + payload["request_id"] = rid + _emit(event, sid, payload) + ev.wait(timeout=timeout) + _pending.pop(rid, None) + return _answers.pop(rid, "") + + +def _clear_pending(): + for rid, ev in list(_pending.items()): + _answers[rid] = "" + ev.set() + + +# ── Agent factory ──────────────────────────────────────────────────── 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) + init_skin_from_config(_load_cfg()) skin = get_active_skin() return {"name": skin.name, "colors": skin.colors, "branding": skin.branding} except Exception: @@ -72,32 +119,25 @@ 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 + m = _load_cfg().get("model", "") + if isinstance(m, dict): + return m.get("default", "") + if isinstance(m, str) and m: + return m 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) + g = 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"), + "input": g("session_input_tokens", "session_prompt_tokens"), + "output": g("session_output_tokens", "session_completion_tokens"), + "total": g("session_total_tokens"), + "calls": g("session_api_calls"), } -def _collect_session_info(agent) -> dict: +def _session_info(agent) -> dict: info: dict = {"model": getattr(agent, "model", ""), "tools": {}, "skills": {}} try: from model_tools import get_toolset_for_tool @@ -114,242 +154,690 @@ def _collect_session_info(agent) -> dict: 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 _agent_cbs(sid: str) -> dict: + return dict( + 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=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), + ) -def _register_approval_notify(sid: str, session_key: str): +def _wire_callbacks(sid: str): + from tools.terminal_tool import set_sudo_password_callback + from tools.skills_tool import set_secret_capture_callback + + set_sudo_password_callback(lambda: _block("sudo.request", sid, {}, timeout=120)) + + def secret_cb(env_var, prompt, metadata=None): + pl = {"prompt": prompt, "env_var": env_var} + if metadata: + pl["metadata"] = metadata + val = _block("secret.request", sid, pl) + if not val: + return {"success": True, "stored_as": env_var, "validated": False, "skipped": True, "message": "skipped"} + from hermes_cli.config import save_env_value_secure + return {**save_env_value_secure(env_var, val), "skipped": False, "message": "ok"} + + set_secret_capture_callback(secret_cb) + + +def _make_agent(sid: str, key: str, session_id: str | None = None): + from run_agent import AIAgent + return AIAgent( + model=_resolve_model(), quiet_mode=True, platform="tui", + session_id=session_id or key, session_db=_get_db(), **_agent_cbs(sid), + ) + + +def _init_session(sid: str, key: str, agent, history: list): + _sessions[sid] = {"agent": agent, "session_key": key, "history": history} try: - from tools.approval import register_gateway_notify - register_gateway_notify(session_key, lambda data: _emit("approval.request", sid, data)) + from tools.approval import register_gateway_notify, load_permanent_allowlist + register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) + load_permanent_allowlist() except Exception: pass + _wire_callbacks(sid) + _emit("session.info", sid, _session_info(agent)) -# ── Methods ────────────────────────────────────────────────────────── +def _with_checkpoints(session, fn): + return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) + + +# ── Methods: session ───────────────────────────────────────────────── @method("session.create") -def _(req_id, params: dict) -> dict: +def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] - session_key = f"tui-{sid}" - - os.environ["HERMES_SESSION_KEY"] = session_key + key = f"tui-{sid}" + os.environ["HERMES_SESSION_KEY"] = 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, "history": []} + agent = _make_agent(sid, key) + _get_db().create_session(key, source="tui", model=_resolve_model()) + _init_session(sid, key, agent, []) 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}) + return _err(rid, 5000, f"agent init failed: {e}") + return _ok(rid, {"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"] - history = session["history"] - _emit("message.start", sid) - - def run(): - try: - result = agent.run_conversation( - text, - conversation_history=list(history), - stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), - ) - - if isinstance(result, dict): - returned_msgs = result.get("messages") - if isinstance(returned_msgs, list): - session["history"] = returned_msgs - 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") - +@method("session.list") +def _(rid, params: dict) -> dict: 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}) + rows = _get_db().list_sessions_rich(source="tui", limit=params.get("limit", 20)) + return _ok(rid, {"sessions": [ + {"id": s["id"], "title": s.get("title") or "", "preview": s.get("preview") or "", + "started_at": s.get("started_at") or 0, "message_count": s.get("message_count") or 0} + for s in rows + ]}) except Exception as e: - return _err(req_id, 5004, str(e)) + return _err(rid, 5006, str(e)) + + +@method("session.resume") +def _(rid, params: dict) -> dict: + target = params.get("session_id", "") + if not target: + return _err(rid, 4006, "session_id required") + db = _get_db() + found = db.get_session(target) + if not found: + found = db.get_session_by_title(target) + if found: + target = found["id"] + else: + return _err(rid, 4007, "session not found") + sid = uuid.uuid4().hex[:8] + os.environ["HERMES_SESSION_KEY"] = target + os.environ["HERMES_INTERACTIVE"] = "1" + try: + db.reopen_session(target) + history = [{"role": m["role"], "content": m["content"]} + for m in db.get_messages(target) + if m.get("role") in ("user", "assistant", "tool", "system")] + agent = _make_agent(sid, target, session_id=target) + _init_session(sid, target, agent, history) + except Exception as e: + return _err(rid, 5000, f"resume failed: {e}") + return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history)}) + + +@method("session.title") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + title, key = params.get("title", ""), session["session_key"] + if not title: + return _ok(rid, {"title": _get_db().get_session_title(key) or "", "session_key": key}) + try: + _get_db().set_session_title(key, title) + return _ok(rid, {"title": title}) + except Exception as e: + return _err(rid, 5007, 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"])) +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + return err or _ok(rid, _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") - return _ok(req_id, {"count": len(session.get("history", []))}) +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + return err or _ok(rid, {"count": len(session.get("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 = session.get("history", []) - removed = 0 +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + history, removed = session.get("history", []), 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}) + return _ok(rid, {"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") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err agent = session["agent"] try: if hasattr(agent, "compress_context"): agent.compress_context() - return _ok(req_id, {"status": "compressed", "usage": _get_usage(agent)}) + return _ok(rid, {"status": "compressed", "usage": _get_usage(agent)}) except Exception as e: - return _err(req_id, 5005, str(e)) + return _err(rid, 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.save") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + import time as _time + filename = f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json" + try: + with open(filename, "w") as f: + json.dump({"model": getattr(session["agent"], "model", ""), "messages": session.get("history", [])}, + f, indent=2, ensure_ascii=False) + return _ok(rid, {"file": filename}) + except Exception as e: + return _err(rid, 5011, str(e)) @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") - +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err if hasattr(session["agent"], "interrupt"): session["agent"].interrupt() - - for rid, ev in list(_clarify_pending.items()): - _clarify_answers[rid] = "" - ev.set() - + _clear_pending() 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"}) + return _ok(rid, {"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") +# ── Methods: prompt ────────────────────────────────────────────────── + +@method("prompt.submit") +def _(rid, params: dict) -> dict: + sid, text = params.get("session_id", ""), params.get("text", "") + session = _sessions.get(sid) + if not session: + return _err(rid, 4001, "session not found") + agent, history = session["agent"], session["history"] + _emit("message.start", sid) + + def run(): + try: + result = agent.run_conversation( + text, conversation_history=list(history), + stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), + ) + if isinstance(result, dict): + if isinstance(result.get("messages"), list): + session["history"] = result["messages"] + status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" + _emit("message.complete", sid, {"text": result.get("final_response", ""), "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(rid, {"status": "streaming"}) + + +@method("prompt.background") +def _(rid, params: dict) -> dict: + text, parent = params.get("text", ""), params.get("session_id", "") + if not text: + return _err(rid, 4012, "text required") + task_id = f"bg_{uuid.uuid4().hex[:6]}" + + def run(): + try: + from run_agent import AIAgent + result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui", + session_id=task_id, max_iterations=30).run_conversation(text) + _emit("background.complete", parent, {"task_id": task_id, + "text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)}) + except Exception as e: + _emit("background.complete", parent, {"task_id": task_id, "text": f"error: {e}"}) + + threading.Thread(target=run, daemon=True).start() + return _ok(rid, {"task_id": task_id}) + + +@method("prompt.btw") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + text, sid = params.get("text", ""), params.get("session_id", "") + if not text: + return _err(rid, 4012, "text required") + snapshot = list(session.get("history", [])) + + def run(): + try: + from run_agent import AIAgent + result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui", + max_iterations=8, enabled_toolsets=[]).run_conversation(text, conversation_history=snapshot) + _emit("btw.complete", sid, {"text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)}) + except Exception as e: + _emit("btw.complete", sid, {"text": f"error: {e}"}) + + threading.Thread(target=run, daemon=True).start() + return _ok(rid, {"status": "running"}) + + +# ── Methods: respond ───────────────────────────────────────────────── + +def _respond(rid, params, key): + r = params.get("request_id", "") + ev = _pending.get(r) + if not ev: + return _err(rid, 4009, f"no pending {key} request") + _answers[r] = params.get(key, "") + ev.set() + return _ok(rid, {"status": "ok"}) + + +@method("clarify.respond") +def _(rid, params: dict) -> dict: + return _respond(rid, params, "answer") + +@method("sudo.respond") +def _(rid, params: dict) -> dict: + return _respond(rid, params, "password") + +@method("secret.respond") +def _(rid, params: dict) -> dict: + return _respond(rid, params, "value") + +@method("approval.respond") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + from tools.approval import resolve_gateway_approval + return _ok(rid, {"resolved": resolve_gateway_approval( + session["session_key"], params.get("choice", "deny"), resolve_all=params.get("all", False))}) + except Exception as e: + return _err(rid, 5004, str(e)) + + +# ── Methods: config ────────────────────────────────────────────────── + +@method("config.set") +def _(rid, params: dict) -> dict: + key, value = params.get("key", ""), params.get("value", "") + + if key == "model": + os.environ["HERMES_MODEL"] = value + return _ok(rid, {"key": key, "value": value}) + + if key == "verbose": + cycle = ["off", "new", "all", "verbose"] + if value and value != "cycle": + os.environ["HERMES_VERBOSE"] = value + return _ok(rid, {"key": key, "value": value}) + cur = os.environ.get("HERMES_VERBOSE", "all") + try: + idx = cycle.index(cur) + except ValueError: + idx = 2 + nv = cycle[(idx + 1) % len(cycle)] + os.environ["HERMES_VERBOSE"] = nv + return _ok(rid, {"key": key, "value": nv}) + + if key == "yolo": + nv = "0" if os.environ.get("HERMES_YOLO", "0") == "1" else "1" + os.environ["HERMES_YOLO"] = nv + return _ok(rid, {"key": key, "value": nv}) + + if key == "reasoning": + if value in ("show", "on"): + os.environ["HERMES_SHOW_REASONING"] = "1" + return _ok(rid, {"key": key, "value": "show"}) + if value in ("hide", "off"): + os.environ.pop("HERMES_SHOW_REASONING", None) + return _ok(rid, {"key": key, "value": "hide"}) + os.environ["HERMES_REASONING"] = value + return _ok(rid, {"key": key, "value": value}) + + if key in ("prompt", "personality", "skin"): + try: + cfg = _load_cfg() + if key == "prompt": + if value == "clear": + cfg.pop("custom_prompt", None) + nv = "" + else: + cfg["custom_prompt"] = value + nv = value + elif key == "personality": + cfg.setdefault("display", {})["personality"] = value if value not in ("none", "default", "neutral") else "" + nv = value + else: + cfg.setdefault("display", {})[key] = value + nv = value + _save_cfg(cfg) + return _ok(rid, {"key": key, "value": nv}) + except Exception as e: + return _err(rid, 5001, str(e)) + + return _err(rid, 4002, f"unknown config key: {key}") + + +@method("config.get") +def _(rid, params: dict) -> dict: + key = params.get("key", "") + if key == "provider": + try: + from hermes_cli.models import list_available_providers, normalize_provider + model = _resolve_model() + parts = model.split("/", 1) + return _ok(rid, {"model": model, "provider": normalize_provider(parts[0]) if len(parts) > 1 else "unknown", + "providers": list_available_providers()}) + except Exception as e: + return _err(rid, 5013, str(e)) + if key == "profile": + from hermes_constants import display_hermes_home + return _ok(rid, {"home": str(_hermes_home), "display": display_hermes_home()}) + if key == "full": + return _ok(rid, {"config": _load_cfg()}) + if key == "prompt": + return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) + return _err(rid, 4002, f"unknown config key: {key}") + + +# ── Methods: tools & system ────────────────────────────────────────── + +@method("process.stop") +def _(rid, params: dict) -> dict: + try: + from tools.process_registry import ProcessRegistry + return _ok(rid, {"killed": ProcessRegistry().kill_all()}) + except Exception as e: + return _err(rid, 5010, str(e)) + + +@method("reload.mcp") +def _(rid, params: dict) -> dict: + session = _sessions.get(params.get("session_id", "")) + try: + from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools + shutdown_mcp_servers() + discover_mcp_tools() + if session: + agent = session["agent"] + if hasattr(agent, "refresh_tools"): + agent.refresh_tools() + _emit("session.info", params.get("session_id", ""), _session_info(agent)) + return _ok(rid, {"status": "reloaded"}) + except Exception as e: + return _err(rid, 5015, str(e)) + + +@method("command.resolve") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.commands import resolve_command + r = resolve_command(params.get("name", "")) + if r: + return _ok(rid, {"canonical": r.name, "description": r.description, "category": r.category}) + return _err(rid, 4011, f"unknown command: {params.get('name')}") + except Exception as e: + return _err(rid, 5012, str(e)) + + +@method("command.dispatch") +def _(rid, params: dict) -> dict: + name, arg = params.get("name", "").lstrip("/"), params.get("arg", "") + session = _sessions.get(params.get("session_id", "")) + + qcmds = _load_cfg().get("quick_commands", {}) + if name in qcmds: + qc = qcmds[name] + if qc.get("type") == "exec": + r = subprocess.run(qc.get("command", ""), shell=True, capture_output=True, text=True, timeout=30) + return _ok(rid, {"type": "exec", "output": (r.stdout or r.stderr)[:4000]}) + if qc.get("type") == "alias": + return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) 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: + from hermes_cli.plugins import get_plugin_command_handler + handler = get_plugin_command_handler(name) + if handler: + return _ok(rid, {"type": "plugin", "output": str(handler(arg) or "")}) + except Exception: 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)") + from agent.skill_commands import scan_skill_commands, build_skill_invocation_message + cmds = scan_skill_commands() + key = f"/{name}" + if key in cmds: + msg = build_skill_invocation_message(key, arg, task_id=session.get("session_key", "") if session else "") + if msg: + return _ok(rid, {"type": "skill", "message": msg, "name": cmds[key].get("name", name)}) + except Exception: + pass + + return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") + + +# ── Methods: voice ─────────────────────────────────────────────────── + +@method("voice.toggle") +def _(rid, params: dict) -> dict: + action = params.get("action", "status") + if action == "status": + return _ok(rid, {"enabled": os.environ.get("HERMES_VOICE", "0") == "1"}) + if action in ("on", "off"): + os.environ["HERMES_VOICE"] = "1" if action == "on" else "0" + return _ok(rid, {"enabled": action == "on"}) + return _err(rid, 4013, f"unknown voice action: {action}") + + +@method("voice.record") +def _(rid, params: dict) -> dict: + action = params.get("action", "start") + try: + if action == "start": + from hermes_cli.voice import start_recording + start_recording() + return _ok(rid, {"status": "recording"}) + if action == "stop": + from hermes_cli.voice import stop_and_transcribe + return _ok(rid, {"text": stop_and_transcribe() or ""}) + return _err(rid, 4019, f"unknown voice action: {action}") + except ImportError: + return _err(rid, 5025, "voice module not available — install audio dependencies") except Exception as e: - return _err(req_id, 5003, str(e)) + return _err(rid, 5025, str(e)) + + +@method("voice.tts") +def _(rid, params: dict) -> dict: + text = params.get("text", "") + if not text: + return _err(rid, 4020, "text required") + try: + from hermes_cli.voice import speak_text + threading.Thread(target=speak_text, args=(text,), daemon=True).start() + return _ok(rid, {"status": "speaking"}) + except ImportError: + return _err(rid, 5026, "voice module not available") + except Exception as e: + return _err(rid, 5026, str(e)) + + +# ── Methods: insights ──────────────────────────────────────────────── + +@method("insights.get") +def _(rid, params: dict) -> dict: + days = params.get("days", 30) + try: + import time + cutoff = time.time() - days * 86400 + rows = [s for s in _get_db().list_sessions_rich(limit=500) if (s.get("started_at") or 0) >= cutoff] + return _ok(rid, {"days": days, "sessions": len(rows), "messages": sum(s.get("message_count", 0) for s in rows)}) + except Exception as e: + return _err(rid, 5017, str(e)) + + +# ── Methods: rollback ──────────────────────────────────────────────── + +@method("rollback.list") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + def go(mgr, cwd): + if not mgr.enabled: + return _ok(rid, {"enabled": False, "checkpoints": []}) + return _ok(rid, {"enabled": True, "checkpoints": [ + {"hash": c.get("hash", ""), "timestamp": c.get("timestamp", ""), "message": c.get("message", "")} + for c in mgr.list_checkpoints(cwd)]}) + return _with_checkpoints(session, go) + except Exception as e: + return _err(rid, 5020, str(e)) + + +@method("rollback.restore") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + target = params.get("hash", "") + if not target: + return _err(rid, 4014, "hash required") + try: + return _ok(rid, _with_checkpoints(session, lambda mgr, cwd: mgr.restore(cwd, target))) + except Exception as e: + return _err(rid, 5021, str(e)) + + +@method("rollback.diff") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + target = params.get("hash", "") + if not target: + return _err(rid, 4014, "hash required") + try: + r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target)) + return _ok(rid, {"stat": r.get("stat", ""), "diff": r.get("diff", "")[:4000]}) + except Exception as e: + return _err(rid, 5022, str(e)) + + +# ── Methods: browser / plugins / cron / skills ─────────────────────── + +@method("browser.manage") +def _(rid, params: dict) -> dict: + action = params.get("action", "status") + if action == "status": + url = os.environ.get("BROWSER_CDP_URL", "") + return _ok(rid, {"connected": bool(url), "url": url}) + if action == "connect": + url = params.get("url", "http://localhost:9222") + os.environ["BROWSER_CDP_URL"] = url + try: + from tools.browser_tool import cleanup_all_browsers + cleanup_all_browsers() + except Exception: + pass + return _ok(rid, {"connected": True, "url": url}) + if action == "disconnect": + os.environ.pop("BROWSER_CDP_URL", None) + try: + from tools.browser_tool import cleanup_all_browsers + cleanup_all_browsers() + except Exception: + pass + return _ok(rid, {"connected": False}) + return _err(rid, 4015, f"unknown action: {action}") + + +@method("plugins.list") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.plugins import get_plugin_manager + return _ok(rid, {"plugins": [ + {"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)} + for n, i in get_plugin_manager()._plugins.items()]}) + except Exception: + return _ok(rid, {"plugins": []}) + + +@method("cron.manage") +def _(rid, params: dict) -> dict: + action, jid = params.get("action", "list"), params.get("name", "") + try: + from tools.cronjob_tools import cronjob + if action == "list": + return _ok(rid, json.loads(cronjob(action="list"))) + if action == "add": + return _ok(rid, json.loads(cronjob(action="create", name=jid, + schedule=params.get("schedule", ""), prompt=params.get("prompt", "")))) + if action in ("remove", "pause", "resume"): + return _ok(rid, json.loads(cronjob(action=action, job_id=jid))) + return _err(rid, 4016, f"unknown cron action: {action}") + except Exception as e: + return _err(rid, 5023, str(e)) + + +@method("skills.manage") +def _(rid, params: dict) -> dict: + action, query = params.get("action", "list"), params.get("query", "") + try: + if action == "list": + from hermes_cli.banner import get_available_skills + return _ok(rid, {"skills": get_available_skills()}) + if action == "search": + from hermes_cli.skills_hub import unified_search, GitHubAuth, create_source_router + raw = unified_search(query, create_source_router(GitHubAuth()), source_filter="all", limit=20) or [] + return _ok(rid, {"results": [{"name": r.name, "description": r.description} for r in raw]}) + if action == "install": + from hermes_cli.skills_hub import do_install + class _Q: + def print(self, *a, **k): pass + do_install(query, skip_confirm=True, console=_Q()) + return _ok(rid, {"installed": True, "name": query}) + if action == "browse": + from hermes_cli.skills_hub import browse_skills + return _ok(rid, {"results": [{"name": r.get("name", ""), "description": r.get("description", "")} + for r in (browse_skills(page=int(query) if query.isdigit() else 1) or [])]}) + if action == "inspect": + from hermes_cli.skills_hub import inspect_skill + return _ok(rid, {"info": inspect_skill(query) or {}}) + return _err(rid, 4017, f"unknown skills action: {action}") + except Exception as e: + return _err(rid, 5024, str(e)) + + +# ── Methods: shell ─────────────────────────────────────────────────── + +@method("shell.exec") +def _(rid, params: dict) -> dict: + cmd = params.get("command", "") + if not cmd: + return _err(rid, 4004, "empty command") + try: + from tools.approval import detect_dangerous_command + is_dangerous, _, desc = detect_dangerous_command(cmd) + if is_dangerous: + return _err(rid, 4005, f"blocked: {desc}. Use the agent for dangerous commands.") + except ImportError: + pass + try: + r = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=30, cwd=os.getcwd()) + return _ok(rid, {"stdout": r.stdout[-4000:], "stderr": r.stderr[-2000:], "code": r.returncode}) + except subprocess.TimeoutExpired: + return _err(rid, 5002, "command timed out (30s)") + except Exception as e: + return _err(rid, 5003, str(e)) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 9623d10add..b6ecf42e71 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -8,14 +8,16 @@ import { CommandPalette } from './components/commandPalette.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' +import { MaskedPrompt } from './components/maskedPrompt.js' +import { SessionPicker } from './components/sessionPicker.js' import { Thinking } from './components/thinking.js' import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' -import type { GatewayClient } from './gatewayClient.js' -import { type GatewayEvent } from './gatewayClient.js' +import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' +import * as inputHistory from './lib/history.js' import { upsert } from './lib/messages.js' import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' -import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SessionInfo, Usage } from './types.js' +import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SecretReq, SessionInfo, SudoReq, Usage } from './types.js' const PLACEHOLDER = pick(PLACEHOLDERS) @@ -39,7 +41,12 @@ export function App({ gw }: { gw: GatewayClient }) { const [usage, setUsage] = useState(ZERO) const [clarify, setClarify] = useState(null) const [approval, setApproval] = useState(null) + const [sudo, setSudo] = useState(null) + const [secret, setSecret] = useState(null) + const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') + const [thinkingText, setThinkingText] = useState('') + const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [queueEditIdx, setQueueEditIdx] = useState(null) const [historyIdx, setHistoryIdx] = useState(null) @@ -49,13 +56,13 @@ export function App({ gw }: { gw: GatewayClient }) { const buf = useRef('') const stickyRef = useRef(true) const queueRef = useRef([]) - const historyRef = useRef([]) + const historyRef = useRef(inputHistory.load()) const historyDraftRef = useRef('') const queueEditRef = useRef(null) const lastEmptyAt = useRef(0) const empty = !messages.length - const blocked = !!(clarify || approval) + const blocked = !!(clarify || approval || sudo || secret || picker) const syncQueue = () => setQueuedDisplay([...queueRef.current]) @@ -84,9 +91,9 @@ export function App({ gw }: { gw: GatewayClient }) { const pushHistory = (text: string) => { const trimmed = text.trim() - if (trimmed && historyRef.current.at(-1) !== trimmed) { historyRef.current.push(trimmed) + inputHistory.append(trimmed) } } @@ -128,13 +135,31 @@ export function App({ gw }: { gw: GatewayClient }) { const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), []) + const rpc = (method: string, params: Record = {}) => + gw.request(method, params).catch((e: Error) => { + sys(`error: ${e.message}`) + }) + + const newSession = (msg?: string) => + rpc('session.create').then((r: any) => { + if (!r) return + setSid(r.session_id) + setMessages([]) + setUsage(ZERO) + setStatus('ready') + if (msg) sys(msg) + }) + const idle = () => { setThinking(false) setTools([]) setBusy(false) setClarify(null) setApproval(null) + setSudo(null) + setSecret(null) setReasoning('') + setThinkingText('') } const die = () => { @@ -229,10 +254,22 @@ export function App({ gw }: { gw: GatewayClient }) { useInput((ch, key) => { if (blocked) { - if (key.ctrl && ch === 'c' && approval) { - gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) - setApproval(null) - sys('denied') + if (key.ctrl && ch === 'c') { + if (approval) { + gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) + setApproval(null) + sys('denied') + } else if (sudo) { + gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) + setSudo(null) + sys('sudo cancelled') + } else if (secret) { + gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) + setSecret(null) + sys('secret entry cancelled') + } else if (picker) { + setPicker(false) + } } return @@ -336,12 +373,7 @@ export function App({ gw }: { gw: GatewayClient }) { } setStatus('forging session…') - gw.request('session.create') - .then((r: any) => { - setSid(r.session_id) - setStatus('ready') - }) - .catch((e: Error) => setStatus(`error: ${e.message}`)) + newSession() break @@ -351,12 +383,14 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'thinking.delta': + if (p?.text) setThinkingText(prev => prev + p.text) break case 'message.start': setThinking(true) setBusy(true) setReasoning('') + setThinkingText('') setStatus('thinking…') break @@ -414,7 +448,24 @@ export function App({ gw }: { gw: GatewayClient }) { case 'approval.request': setApproval({ command: p.command, description: p.description }) setStatus('approval needed') + break + case 'sudo.request': + setSudo({ requestId: p.request_id }) + setStatus('sudo password needed') + break + + case 'secret.request': + setSecret({ requestId: p.request_id, prompt: p.prompt, envVar: p.env_var }) + setStatus('secret input needed') + break + + case 'background.complete': + sys(`[bg ${p.task_id}] ${p.text}`) + break + + case 'btw.complete': + sys(`[btw] ${p.text}`) break case 'message.delta': @@ -474,7 +525,7 @@ export function App({ gw }: { gw: GatewayClient }) { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sys] + [gw, sys, newSession] ) useEffect(() => { @@ -509,8 +560,8 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'clear': - setMessages([]) - + setStatus('forging session…') + newSession() return true case 'quit': // falls through @@ -522,16 +573,7 @@ export function App({ gw }: { gw: GatewayClient }) { 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}`)) - + newSession('new session started') return true case 'undo': @@ -539,7 +581,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('session.undo', { session_id: sid }) + rpc('session.undo', { session_id: sid }) .then((r: any) => { if (r.removed > 0) { setMessages(prev => { @@ -560,28 +602,23 @@ export function App({ gw }: { gw: GatewayClient }) { sys('nothing to undo') } }) - .catch((e: Error) => sys(`error: ${e.message}`)) return true case 'retry': if (!lastUserMsg) { sys('nothing to retry') - return true } - + if (sid) { + gw.request('session.undo', { session_id: sid }).catch(() => {}) + } setMessages(prev => { const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() return q }) send(lastUserMsg) - return true case 'compact': @@ -595,7 +632,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('session.compress', { session_id: sid }) + rpc('session.compress', { session_id: sid }) .then((r: any) => { sys('context compressed') @@ -603,7 +640,6 @@ export function App({ gw }: { gw: GatewayClient }) { setUsage(r.usage) } }) - .catch((e: Error) => sys(`error: ${e.message}`)) return true @@ -656,19 +692,321 @@ export function App({ gw }: { gw: GatewayClient }) { return true - case 'skills': - if (!info?.skills || !Object.keys(info.skills).length) { - sys('no skills loaded') + case 'resume': + setPicker(true) + return true + case 'history': + if (!sid) { setPicker(true); return true } + rpc('session.history', { session_id: sid }) + .then((r: any) => sys(`session ${sid}: ${r.count} messages in context`)) + return true + + case 'title': + if (!sid) return true + if (!arg) { + rpc('session.title', { session_id: sid }) + .then((r: any) => sys(`title: ${r.title || '(none)'} session: ${r.session_key}`)) return true } + rpc('session.title', { session_id: sid, title: arg }) + .then(() => sys(`title → ${arg}`)) + return true + case 'tools': + if (!info?.tools || !Object.keys(info.tools).length) { + sys('no tools loaded') + return true + } sys( - Object.entries(info.skills) - .map(([k, vs]) => `${k}: ${vs.join(', ')}`) + Object.entries(info.tools) + .map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`) .join('\n') ) + return true + case 'skills': + if (!arg || arg === 'list') { + 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 + } + if (arg.startsWith('search ')) { + rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }) + .then((r: any) => { + if (!r.results?.length) { sys('no results'); return } + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true + } + if (arg.startsWith('install ')) { + rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }) + .then((r: any) => sys(r.installed ? `installed ${r.name}` : 'install failed')) + return true + } + if (arg === 'browse' || arg.startsWith('browse ')) { + rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }) + .then((r: any) => { + if (!r.results?.length) { sys('no skills available'); return } + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true + } + if (arg.startsWith('inspect ')) { + rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }) + .then((r: any) => sys(JSON.stringify(r.info, null, 2))) + return true + } + sys('usage: /skills [list|search |install |browse|inspect ]') + return true + + case 'verbose': + rpc('config.set', { key: 'verbose', value: arg || 'cycle' }) + .then((r: any) => sys(`verbose → ${r.value}`)) + return true + + case 'yolo': + rpc('config.set', { key: 'yolo', value: '' }) + .then((r: any) => sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`)) + return true + + case 'reasoning': + if (!arg) { + sys('usage: /reasoning ') + return true + } + rpc('config.set', { key: 'reasoning', value: arg }) + .then((r: any) => sys(`reasoning → ${r.value}`)) + return true + + case 'stop': + rpc('process.stop') + .then((r: any) => sys(`killed ${r.killed} process(es)`)) + return true + + case 'profile': + gw.request('config.get', { key: 'profile' }) + .then((r: any) => sys(`profile: ${r.display}`)) + .catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`)) + return true + + case 'save': + if (!sid) return true + rpc('session.save', { session_id: sid }) + .then((r: any) => sys(`saved to ${r.file}`)) + return true + + case 'provider': + rpc('config.get', { key: 'provider' }) + .then((r: any) => { + const lines = [`model: ${r.model} provider: ${r.provider}`] + if (r.providers?.length) lines.push(`available: ${r.providers.join(', ')}`) + sys(lines.join('\n')) + }) + return true + + case 'prompt': + if (!arg) { + rpc('config.get', { key: 'prompt' }) + .then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) + return true + } + rpc('config.set', { key: 'prompt', value: arg }) + .then((r: any) => sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared')) + return true + + case 'personality': + if (!arg) { + sys('usage: /personality (concise, creative, analytical, friendly, none)') + return true + } + rpc('config.set', { key: 'personality', value: arg }) + .then((r: any) => sys(`personality → ${r.value || 'default'}`)) + return true + + case 'plan': + send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.') + return true + + case 'background': + case 'bg': + if (!arg) { + sys('usage: /background ') + return true + } + rpc('prompt.background', { session_id: sid, text: arg }) + .then((r: any) => sys(`background task ${r.task_id} started`)) + return true + + case 'btw': + if (!arg) { + sys('usage: /btw ') + return true + } + rpc('prompt.btw', { session_id: sid, text: arg }) + .then(() => sys('btw running…')) + return true + + case 'queue': + if (!arg) { + sys(`${queueRef.current.length} queued message(s)`) + return true + } + enqueue(arg) + sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + return true + + case 'rollback': + if (!sid) return true + if (!arg) { + rpc('rollback.list', { session_id: sid }) + .then((r: any) => { + if (!r.enabled) { sys('checkpoints not enabled — use hermes --checkpoints'); return } + if (!r.checkpoints?.length) { sys('no checkpoints'); return } + sys(r.checkpoints.map((c: any, i: number) => + ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})` + ).join('\n')) + }) + return true + } + if (arg.startsWith('diff ')) { + const ref = arg.slice(5).trim() + rpc('rollback.list', { session_id: sid }).then((r: any) => { + const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref + if (!hash) { sys(`checkpoint ${ref} not found`); return } + rpc('rollback.diff', { session_id: sid, hash }) + .then((d: any) => sys(d.stat || d.diff || 'no changes')) + }) + return true + } + { + const parts = arg.trim().split(/\s+/) + const ref = parts[0]! + const file = parts[1] + rpc('rollback.list', { session_id: sid }).then((r: any) => { + const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref + if (!hash) { sys(`checkpoint ${ref} not found`); return } + rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }) + .then((d: any) => sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`)) + }) + } + return true + + case 'insights': + rpc('insights.get', { days: arg ? parseInt(arg) : 30 }) + .then((r: any) => sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`)) + return true + + case 'toolsets': + if (!info?.tools) { + sys('no toolsets loaded') + return true + } + sys(Object.entries(info.tools).map(([k, vs]) => `${k}: ${vs.length} tools`).join('\n')) + return true + + case 'paste': + sys('clipboard paste: use your terminal\'s paste shortcut (images not yet supported in TUI)') + return true + + case 'reload-mcp': + case 'reload_mcp': + rpc('reload.mcp', { session_id: sid }) + .then(() => sys('MCP servers reloaded')) + return true + + case 'browser': + if (!arg || arg === 'status') { + rpc('browser.manage', { action: 'status' }) + .then((r: any) => sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected')) + } else if (arg === 'connect' || arg.startsWith('connect ')) { + const url = arg.split(/\s+/)[1] + rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }) + .then((r: any) => sys(`browser connected: ${r.url}`)) + } else if (arg === 'disconnect') { + rpc('browser.manage', { action: 'disconnect' }) + .then(() => sys('browser disconnected')) + } else { + sys('usage: /browser [connect|disconnect|status]') + } + return true + + case 'platforms': + case 'gateway': + sys('gateway status is not available in TUI mode') + return true + + case 'statusbar': + case 'sb': + setStatusBar(v => !v) + sys(`status bar ${statusBar ? 'off' : 'on'}`) + return true + + case 'voice': + if (!arg || arg === 'status') { + rpc('voice.toggle', { action: 'status' }) + .then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) + } else if (arg === 'on' || arg === 'off') { + rpc('voice.toggle', { action: arg }) + .then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) + } else if (arg === 'record') { + rpc('voice.record', { action: 'start' }) + .then(() => sys('recording… (use /voice stop to transcribe)')) + } else if (arg === 'stop') { + rpc('voice.record', { action: 'stop' }) + .then((r: any) => { + if (r.text) { send(r.text) } else { sys('no speech detected') } + }) + } else if (arg === 'tts') { + const last = messages.filter(m => m.role === 'assistant').at(-1) + if (last) { + rpc('voice.tts', { text: last.text }) + .then(() => sys('speaking…')) + } else { + sys('no response to speak') + } + } else { + sys('usage: /voice [on|off|status|record|stop|tts]') + } + return true + + case 'plugins': + rpc('plugins.list') + .then((r: any) => { + if (!r.plugins?.length) { sys('no plugins installed'); return } + sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) + }) + return true + + case 'cron': + if (!arg || arg === 'list') { + rpc('cron.manage', { action: 'list' }) + .then((r: any) => { + const jobs = r.jobs || r.schedules || [] + if (!jobs.length) { sys('no cron jobs'); return } + sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) + }) + } else { + const parts = arg.split(/\s+/) + const sub = parts[0]! + if (sub === 'add' || sub === 'create') { + const name = parts[1] || '' + const schedule = parts[2] || '' + const prompt = parts.slice(3).join(' ') + rpc('cron.manage', { action: 'add', name, schedule, prompt }) + .then((r: any) => sys(r.message || r.status || 'created')) + } else { + rpc('cron.manage', { action: sub, name: parts[1] || '' }) + .then((r: any) => sys(r.message || r.status || JSON.stringify(r))) + } + } + return true + + case 'update': + sys('update not available in TUI mode — run: pip install -U hermes-agent') return true case 'model': @@ -678,9 +1016,8 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('config.set', { key: 'model', value: arg }) + rpc('config.set', { key: 'model', value: arg }) .then(() => sys(`model → ${arg}`)) - .catch((e: Error) => sys(`error: ${e.message}`)) return true @@ -691,18 +1028,42 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - gw.request('config.set', { key: 'skin', value: arg }) + rpc('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.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) + .then((r: any) => { + if (r.type === 'exec') { + sys(r.output || '(no output)') + } else if (r.type === 'alias') { + slash(`/${r.target}${arg ? ' ' + arg : ''}`) + } else if (r.type === 'plugin') { + sys(r.output || '(no output)') + } else if (r.type === 'skill') { + sys(`⚡ loading skill: ${r.name}`) + send(r.message) + } + }) + .catch(() => { + gw.request('command.resolve', { name: name ?? '' }) + .then((r: any) => { + if (r.canonical && r.canonical !== name) { + sys(`/${name} → /${r.canonical}`) + slash(`/${r.canonical}${arg ? ' ' + arg : ''}`) + } else { + sys(`unknown command: /${name}`) + } + }) + .catch(() => sys(`unknown command: /${name}`)) + }) + return true } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [compact, gw, info, lastUserMsg, messages, sid, status, sys, usage] + [compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar] ) const submit = useCallback( @@ -878,7 +1239,7 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {thinking && } + {thinking && } {clarify && ( @@ -907,6 +1268,57 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} + {sudo && ( + { + gw.request('sudo.respond', { request_id: sudo.requestId, password }).catch(() => {}) + setSudo(null) + setStatus('running…') + }} + t={theme} + /> + )} + + {secret && ( + { + gw.request('secret.respond', { request_id: secret.requestId, value }).catch(() => {}) + setSecret(null) + setStatus('running…') + }} + sub={`for ${secret.envVar}`} + t={theme} + /> + )} + + {picker && ( + setPicker(false)} + onSelect={id => { + setPicker(false) + setStatus('resuming…') + gw.request('session.resume', { session_id: id }) + .then((r: any) => { + setSid(r.session_id) + setMessages([]) + setUsage(ZERO) + sys(`resumed session (${r.message_count} messages)`) + setStatus('ready') + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + }) + }} + t={theme} + /> + )} + {!blocked && input.startsWith('/') && } diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index c0d71403bf..99f24a59b1 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -144,6 +144,52 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } + if (line.match(/^>\s?/)) { + const quoteLines: string[] = [] + while (i < lines.length && lines[i]!.match(/^>\s?/)) { + quoteLines.push(lines[i]!.replace(/^>\s?/, '')) + i++ + } + nodes.push( + + {quoteLines.map((ql, qi) => ( + + {' │ '} + + ))} + + ) + continue + } + + if (line.includes('|') && line.trim().startsWith('|')) { + const tableRows: string[][] = [] + while (i < lines.length && lines[i]!.trim().startsWith('|')) { + const row = lines[i]!.trim() + if (!/^[|\s:-]+$/.test(row)) { + tableRows.push( + row.split('|').filter(Boolean).map(c => c.trim()) + ) + } + i++ + } + if (tableRows.length) { + const widths = tableRows[0]!.map((_, ci) => + Math.max(...tableRows.map(r => (r[ci] ?? '').length)) + ) + nodes.push( + + {tableRows.map((row, ri) => ( + + {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} + + ))} + + ) + } + continue + } + nodes.push() i++ } diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx new file mode 100644 index 0000000000..96fe21e1c8 --- /dev/null +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -0,0 +1,29 @@ +import { Box, Text } from 'ink' +import TextInput from 'ink-text-input' +import { useState } from 'react' + +import type { Theme } from '../theme.js' + +export function MaskedPrompt({ + icon, label, onSubmit, sub, t +}: { + icon: string + label: string + onSubmit: (v: string) => void + sub?: string + t: Theme +}) { + const [value, setValue] = useState('') + + return ( + + {icon} {label} + {sub && {sub}} + + + {'> '} + + + + ) +} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx new file mode 100644 index 0000000000..baa8ad25b2 --- /dev/null +++ b/ui-tui/src/components/sessionPicker.tsx @@ -0,0 +1,94 @@ +import { Box, Text, useInput } from 'ink' +import { useEffect, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' +import type { Theme } from '../theme.js' + +interface SessionItem { + id: string + title: string + preview: string + started_at: number + message_count: number +} + +function age(ts: number): string { + const d = (Date.now() / 1000 - ts) / 86400 + if (d < 1) return 'today' + if (d < 2) return 'yesterday' + return `${Math.floor(d)}d ago` +} + +const VISIBLE = 15 + +export function SessionPicker({ + gw, + onCancel, + onSelect, + t +}: { + gw: GatewayClient + onCancel: () => void + onSelect: (id: string) => void + t: Theme +}) { + const [items, setItems] = useState([]) + const [sel, setSel] = useState(0) + const [loading, setLoading] = useState(true) + + useEffect(() => { + gw.request('session.list', { limit: 20 }) + .then((r: any) => { + setItems(r.sessions ?? []) + setLoading(false) + }) + .catch(() => setLoading(false)) + }, [gw]) + + useInput((ch, key) => { + if (key.escape) return onCancel() + if (key.upArrow && sel > 0) setSel(s => s - 1) + if (key.downArrow && sel < items.length - 1) setSel(s => s + 1) + if (key.return && items[sel]) onSelect(items[sel]!.id) + + const n = parseInt(ch) + if (n >= 1 && n <= Math.min(9, items.length)) onSelect(items[n - 1]!.id) + }) + + if (loading) return loading sessions… + + if (!items.length) { + return ( + + no previous sessions + Esc to cancel + + ) + } + + const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) + const visible = items.slice(off, off + VISIBLE) + + return ( + + Resume Session + {off > 0 && ↑ {off} more} + {visible.map((s, vi) => { + const i = off + vi + return ( + + {sel === i ? '▸ ' : ' '} + + {i + 1}. {s.title || s.preview || s.id.slice(0, 8)} + + + {' '}({s.message_count} msgs, {age(s.started_at)}) + + + ) + })} + {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} + ↑/↓ select · Enter resume · 1-9 quick · Esc cancel + + ) +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index f5d5cd3da2..f5e7351fad 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -6,7 +6,7 @@ import { pick } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; tools: ActiveTool[] }) { +export function Thinking({ reasoning, t, thinking, tools }: { reasoning: string; t: Theme; thinking?: string; tools: ActiveTool[] }) { const [frame, setFrame] = useState(0) const [verb] = useState(() => pick(VERBS)) const [face] = useState(() => pick(FACES)) @@ -30,10 +30,10 @@ export function Thinking({ reasoning, t, tools }: { reasoning: string; t: Theme; {SPINNER[frame]} {face} {verb}… )} - {reasoning && ( + {(reasoning || thinking) && ( {' 💭 '} - {reasoning.slice(-120).replace(/\n/g, ' ')} + {(reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')} )} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index a8d2a99c12..5b921807b0 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -3,20 +3,45 @@ import type { Role, Usage } from './types.js' export const COMMANDS: [string, string][] = [ ['/help', 'commands & hotkeys'], - ['/model', 'switch model'], - ['/skin', 'change theme'], - ['/clear', 'reset chat'], ['/new', 'new session'], + ['/resume', 'resume a previous session'], + ['/title', 'set session title'], + ['/history', 'show session list'], + ['/clear', 'reset session + chat'], ['/undo', 'drop last exchange'], ['/retry', 'resend last message'], + ['/save', 'save conversation to file'], ['/compact', 'toggle compact [focus]'], - ['/cost', 'token usage stats'], - ['/copy', 'copy last response'], - ['/context', 'context window info'], ['/compress', 'compress context'], + ['/model', 'switch model'], + ['/skin', 'change theme'], + ['/provider', 'show model/provider info'], + ['/prompt', 'set custom system prompt'], + ['/personality', 'set personality preset'], + ['/verbose', 'cycle tool verbosity'], + ['/yolo', 'toggle auto-approve mode'], + ['/reasoning', 'set reasoning level'], + ['/tools', 'list active tools'], + ['/toolsets', 'list toolsets'], ['/skills', 'list skills'], + ['/stop', 'kill background processes'], + ['/background', 'run prompt in background'], + ['/btw', 'side question (no tools)'], + ['/plan', 'invoke plan skill'], + ['/queue', 'queue prompt for next turn'], + ['/profile', 'show active profile'], + ['/cost', 'token usage stats'], + ['/context', 'context window info'], + ['/insights', 'usage analytics'], + ['/copy', 'copy last response'], + ['/paste', 'clipboard info'], ['/config', 'show config'], ['/status', 'session info'], + ['/statusbar', 'toggle status bar'], + ['/voice', 'voice mode toggle'], + ['/reload-mcp', 'reload MCP servers'], + ['/rollback', 'checkpoint info'], + ['/browser', 'browser tools info'], ['/quit', 'exit hermes'] ] @@ -47,7 +72,9 @@ export const HOTKEYS: [string, string][] = [ ['Esc', 'clear input'], ['\\+Enter', 'multi-line continuation'], ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'] + ['{!cmd}', 'interpolate shell output inline'], + ['/voice record', 'start PTT recording'], + ['/voice stop', 'stop + transcribe'] ] export const INTERPOLATION_RE = /\{!(.+?)\}/g diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts new file mode 100644 index 0000000000..b77ba44439 --- /dev/null +++ b/ui-tui/src/lib/history.ts @@ -0,0 +1,52 @@ +import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' + +const MAX = 1000 +const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes')) +const file = join(dir, 'tui_history') + +let cache: string[] | null = null + +function encode(s: string): string { + return s.replace(/\\/g, '\\\\').replace(/\n/g, '\\n') +} + +function decode(s: string): string { + return s.replace(/\\n/g, '\n').replace(/\\\\/g, '\\') +} + +export function load(): string[] { + if (cache) return cache + try { + if (existsSync(file)) { + cache = readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .map(decode) + .slice(-MAX) + } else { + cache = [] + } + } catch { + cache = [] + } + return cache +} + +export function append(line: string): void { + const trimmed = line.trim() + if (!trimmed) return + const items = load() + if (items.at(-1) === trimmed) return + items.push(trimmed) + if (items.length > MAX) items.splice(0, items.length - MAX) + try { + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + appendFileSync(file, encode(trimmed) + '\n') + } catch { /* ignore */ } +} + +export function all(): string[] { + return load() +} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4b4084eb4d..253c46069c 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -33,3 +33,13 @@ export interface Usage { output: number total: number } + +export interface SudoReq { + requestId: string +} + +export interface SecretReq { + envVar: string + prompt: string + requestId: string +} From fab4d8d470f72bc3de48f93ec3e89e7131eb1e57 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Apr 2026 19:52:50 -0500 Subject: [PATCH 006/157] chore: uptick --- tui_gateway/entry.py | 10 ++++++++-- ui-tui/src/entry.tsx | 3 +-- ui-tui/src/main.tsx | 3 +-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 72a4537f4f..697b75b596 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -1,12 +1,18 @@ import json +import signal import sys from tui_gateway.server import handle_request, resolve_skin +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + def _write(obj: dict): - sys.stdout.write(json.dumps(obj) + "\n") - sys.stdout.flush() + try: + sys.stdout.write(json.dumps(obj) + "\n") + sys.stdout.flush() + except BrokenPipeError: + sys.exit(0) def main(): diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index ecb9e4a829..cc0a15b4c9 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,5 +1,4 @@ -'use strict' - +import React from 'react' import { render } from 'ink' import { App } from './app.js' diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx index ecb9e4a829..cc0a15b4c9 100644 --- a/ui-tui/src/main.tsx +++ b/ui-tui/src/main.tsx @@ -1,5 +1,4 @@ -'use strict' - +import React from 'react' import { render } from 'ink' import { App } from './app.js' From 56a69e519b1fdb8b2b73aec00a4045f951e91ef4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Apr 2026 19:55:15 -0500 Subject: [PATCH 007/157] chore: uptick --- ui-tui/src/app.tsx | 3 ++- ui-tui/src/components/markdown.tsx | 31 +++++++++++++++--------------- ui-tui/src/lib/osc52.ts | 3 +++ 3 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 ui-tui/src/lib/osc52.ts diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index b6ecf42e71..6cd3775d39 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -14,6 +14,7 @@ import { Thinking } from './components/thinking.js' import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import * as inputHistory from './lib/history.js' +import { writeOsc52Clipboard } from './lib/osc52.js' import { upsert } from './lib/messages.js' import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' @@ -661,7 +662,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - process.stdout.write(`\x1b]52;c;${Buffer.from(target.text).toString('base64')}\x07`) + writeOsc52Clipboard(target.text) sys('copied to clipboard') return true diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 99f24a59b1..adee83ad28 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -8,50 +8,51 @@ function MdInline({ t, text }: { t: Theme; text: string }) { const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g let last = 0 - let match: RegExpExecArray | null - while ((match = re.exec(text)) !== null) { - if (match.index > last) { + for (const m of text.matchAll(re)) { + const i = m.index ?? 0 + + if (i > last) { parts.push( - {text.slice(last, match.index)} + {text.slice(last, i)} ) } - if (match[2] && match[3]) { + if (m[2] && m[3]) { parts.push( - {match[2]} + {m[2]} ) - } else if (match[4]) { + } else if (m[4]) { parts.push( - {match[4]} + {m[4]} ) - } else if (match[5]) { + } else if (m[5]) { parts.push( - {match[5]} + {m[5]} ) - } else if (match[6]) { + } else if (m[6]) { parts.push( - {match[6]} + {m[6]} ) - } else if (match[7]) { + } else if (m[7]) { parts.push( - {match[7]} + {m[7]} ) } - last = match.index + match[0].length + last = i + m[0].length } if (last < text.length) { diff --git a/ui-tui/src/lib/osc52.ts b/ui-tui/src/lib/osc52.ts new file mode 100644 index 0000000000..01688aca6a --- /dev/null +++ b/ui-tui/src/lib/osc52.ts @@ -0,0 +1,3 @@ +export function writeOsc52Clipboard(s: string): void { + process.stdout.write('\x1b]52;c;' + Buffer.from(s, 'utf8').toString('base64') + '\x07') +} From 5a5d90c85a023e62bb85e6aaca099290a376072e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 3 Apr 2026 20:14:57 -0500 Subject: [PATCH 008/157] chore: formatting etc --- tui_gateway/server.py | 72 +++ ui-tui/eslint.config.mjs | 3 +- ui-tui/src/app.tsx | 529 ++++++++++++++++------- ui-tui/src/components/commandPalette.tsx | 14 +- ui-tui/src/components/markdown.tsx | 21 +- ui-tui/src/components/maskedPrompt.tsx | 12 +- ui-tui/src/components/sessionPicker.tsx | 51 ++- ui-tui/src/components/thinking.tsx | 12 +- ui-tui/src/constants.ts | 45 +- ui-tui/src/entry.tsx | 2 +- ui-tui/src/lib/history.ts | 41 +- ui-tui/src/lib/slash.ts | 124 ++++++ ui-tui/src/main.tsx | 2 +- ui-tui/src/types.ts | 7 + 14 files changed, 698 insertions(+), 237 deletions(-) create mode 100644 ui-tui/src/lib/slash.ts diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c8262e639f..c0e9849ae8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -579,6 +579,78 @@ def _(rid, params: dict) -> dict: return _err(rid, 5015, str(e)) +@method("commands.catalog") +def _(rid, params: dict) -> dict: + """Registry-backed slash metadata (same surface as SlashCommandCompleter).""" + try: + from hermes_cli.commands import COMMAND_REGISTRY, COMMANDS, SUBCOMMANDS + + pairs = sorted(COMMANDS.items(), key=lambda kv: kv[0]) + sub = {k: v[:] for k, v in SUBCOMMANDS.items()} + canon: dict[str, str] = {} + for cmd in COMMAND_REGISTRY: + if cmd.gateway_only: + continue + c = f"/{cmd.name}" + canon[c.lower()] = c + for a in cmd.aliases: + canon[f"/{a}".lower()] = c + skills = [] + try: + from agent.skill_commands import scan_skill_commands + for k, info in scan_skill_commands().items(): + d = str(info.get("description", "Skill")) + skills.append([k, f"⚡ {d[:120]}{'…' if len(d) > 120 else ''}"]) + except Exception: + pass + return _ok(rid, {"pairs": pairs + skills, "sub": sub, "canon": canon}) + except Exception as e: + return _err(rid, 5020, str(e)) + + +def _cli_exec_blocked(argv: list[str]) -> str | None: + """Return user hint if this argv must not run headless in the gateway process.""" + if not argv: + return "bare `hermes` is interactive — use `/hermes chat -q …` or run `hermes` in another terminal" + a0 = argv[0].lower() + if a0 == "setup": + return "`hermes setup` needs a full terminal — run it outside the Ink UI" + if a0 == "gateway": + return "`hermes gateway` is long-running — run it in another terminal" + if a0 == "sessions" and len(argv) > 1 and argv[1].lower() == "browse": + return "`hermes sessions browse` is interactive — use /resume here, or run browse in another terminal" + if a0 == "config" and len(argv) > 1 and argv[1].lower() == "edit": + return "`hermes config edit` needs $EDITOR in a real terminal" + return None + + +@method("cli.exec") +def _(rid, params: dict) -> dict: + """Run `python -m hermes_cli.main` with argv; capture stdout/stderr (non-interactive only).""" + argv = params.get("argv", []) + if not isinstance(argv, list) or not all(isinstance(x, str) for x in argv): + return _err(rid, 4003, "argv must be list[str]") + hint = _cli_exec_blocked(argv) + if hint: + return _ok(rid, {"blocked": True, "hint": hint, "code": -1, "output": ""}) + try: + r = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", *argv], + capture_output=True, + text=True, + timeout=min(int(params.get("timeout", 240)), 600), + cwd=os.getcwd(), + env=os.environ.copy(), + ) + parts = [r.stdout or "", r.stderr or ""] + out = "\n".join(p for p in parts if p).strip() or "(no output)" + return _ok(rid, {"blocked": False, "code": r.returncode, "output": out[:48_000]}) + except subprocess.TimeoutExpired: + return _err(rid, 5016, "cli.exec: timeout") + except Exception as e: + return _err(rid, 5017, str(e)) + + @method("command.resolve") def _(rid, params: dict) -> dict: try: diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs index 6cb1c419a3..905e734b8d 100644 --- a/ui-tui/eslint.config.mjs +++ b/ui-tui/eslint.config.mjs @@ -28,6 +28,7 @@ export default [ 'unused-imports': unusedImports }, rules: { + 'no-fallthrough': ['error', { allowEmptyCase: true }], curly: ['error', 'all'], '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], '@typescript-eslint/no-unused-vars': 'off', @@ -63,6 +64,6 @@ export default [ } }, { - ignores: ['node_modules/', 'dist/', '*.config.*'] + ignores: ['node_modules/', 'dist/', '*.config.*', 'src/**/*.js'] } ] diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 6cd3775d39..774e47948a 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -5,20 +5,31 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { AltScreen } from './altScreen.js' import { Banner, SessionPanel } from './components/branding.js' import { CommandPalette } from './components/commandPalette.js' +import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' -import { MaskedPrompt } from './components/maskedPrompt.js' import { SessionPicker } from './components/sessionPicker.js' import { Thinking } from './components/thinking.js' -import { COMMANDS, HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import * as inputHistory from './lib/history.js' -import { writeOsc52Clipboard } from './lib/osc52.js' import { upsert } from './lib/messages.js' +import { writeOsc52Clipboard } from './lib/osc52.js' +import { paletteForLine, tabAdvance } from './lib/slash.js' import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' -import type { ActiveTool, ApprovalReq, ClarifyReq, Msg, SecretReq, SessionInfo, SudoReq, Usage } from './types.js' +import type { + ActiveTool, + ApprovalReq, + ClarifyReq, + Msg, + SecretReq, + SessionInfo, + SlashCatalog, + SudoReq, + Usage +} from './types.js' const PLACEHOLDER = pick(PLACEHOLDERS) @@ -53,6 +64,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [historyIdx, setHistoryIdx] = useState(null) const [scrollOffset, setScrollOffset] = useState(0) const [queuedDisplay, setQueuedDisplay] = useState([]) + const [catalog, setCatalog] = useState(null) const buf = useRef('') const stickyRef = useRef(true) @@ -92,6 +104,7 @@ export function App({ gw }: { gw: GatewayClient }) { const pushHistory = (text: string) => { const trimmed = text.trim() + if (trimmed && historyRef.current.at(-1) !== trimmed) { historyRef.current.push(trimmed) inputHistory.append(trimmed) @@ -143,12 +156,18 @@ export function App({ gw }: { gw: GatewayClient }) { const newSession = (msg?: string) => rpc('session.create').then((r: any) => { - if (!r) return + if (!r) { + return + } + setSid(r.session_id) setMessages([]) setUsage(ZERO) setStatus('ready') - if (msg) sys(msg) + + if (msg) { + sys(msg) + } }) const idle = () => { @@ -276,6 +295,16 @@ export function App({ gw }: { gw: GatewayClient }) { return } + if (!inputBuf.length && key.tab && input.startsWith('/')) { + const next = tabAdvance(input, catalog) + + if (next) { + setInput(next) + } + + return + } + if (key.pageUp) { scrollUp(5) @@ -373,6 +402,20 @@ export function App({ gw }: { gw: GatewayClient }) { setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) } + rpc('commands.catalog', {}) + .then((r: any) => { + if (!r?.pairs) { + return + } + + setCatalog({ + canon: (r.canon ?? {}) as Record, + pairs: r.pairs as [string, string][], + sub: (r.sub ?? {}) as Record + }) + }) + .catch(() => {}) + setStatus('forging session…') newSession() @@ -384,7 +427,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'thinking.delta': - if (p?.text) setThinkingText(prev => prev + p.text) + if (p?.text) { + setThinkingText(prev => prev + p.text) + } + break case 'message.start': @@ -449,24 +495,29 @@ export function App({ gw }: { gw: GatewayClient }) { case 'approval.request': setApproval({ command: p.command, description: p.description }) setStatus('approval needed') + break case 'sudo.request': setSudo({ requestId: p.request_id }) setStatus('sudo password needed') + break case 'secret.request': setSecret({ requestId: p.request_id, prompt: p.prompt, envVar: p.env_var }) setStatus('secret input needed') + break case 'background.complete': sys(`[bg ${p.task_id}] ${p.text}`) + break case 'btw.complete': sys(`[btw] ${p.text}`) + break case 'message.delta': @@ -547,22 +598,31 @@ export function App({ gw }: { gw: GatewayClient }) { const arg = rest.join(' ') switch (name) { - case 'help': + case 'help': { + const rows = catalog?.pairs ?? [] + const cap = 52 + const lines = rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`) + sys( [ ' Commands:', - ...COMMANDS.map(([c, d]) => ` ${c.padEnd(12)} ${d}`), + ...lines, + rows.length > cap ? ` … ${rows.length - cap} more` : '', '', ' Hotkeys:', - ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(12)} ${d}`) - ].join('\n') + ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`) + ] + .filter(Boolean) + .join('\n') ) return true + } case 'clear': setStatus('forging session…') newSession() + return true case 'quit': // falls through @@ -575,6 +635,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'new': setStatus('forging session…') newSession('new session started') + return true case 'undo': @@ -582,44 +643,51 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('session.undo', { session_id: sid }) - .then((r: any) => { - if (r.removed > 0) { - setMessages(prev => { - const q = [...prev] + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (r.removed > 0) { + setMessages(prev => { + const q = [...prev] - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } - if (q.at(-1)?.role === 'user') { - q.pop() - } + if (q.at(-1)?.role === 'user') { + q.pop() + } - return q - }) - sys(`undid ${r.removed} messages`) - } else { - sys('nothing to undo') - } - }) + return q + }) + sys(`undid ${r.removed} messages`) + } else { + sys('nothing to undo') + } + }) return true case 'retry': if (!lastUserMsg) { sys('nothing to retry') + return true } + if (sid) { gw.request('session.undo', { session_id: sid }).catch(() => {}) } + setMessages(prev => { const q = [...prev] - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + return q }) send(lastUserMsg) + return true case 'compact': @@ -633,14 +701,13 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('session.compress', { session_id: sid }) - .then((r: any) => { - sys('context compressed') + rpc('session.compress', { session_id: sid }).then((r: any) => { + sys('context compressed') - if (r.usage) { - setUsage(r.usage) - } - }) + if (r.usage) { + setUsage(r.usage) + } + }) return true @@ -695,319 +762,484 @@ export function App({ gw }: { gw: GatewayClient }) { case 'resume': setPicker(true) + return true case 'history': - if (!sid) { setPicker(true); return true } - rpc('session.history', { session_id: sid }) - .then((r: any) => sys(`session ${sid}: ${r.count} messages in context`)) + if (!sid) { + setPicker(true) + + return true + } + + rpc('session.history', { session_id: sid }).then((r: any) => + sys(`session ${sid}: ${r.count} messages in context`) + ) + return true case 'title': - if (!sid) return true - if (!arg) { - rpc('session.title', { session_id: sid }) - .then((r: any) => sys(`title: ${r.title || '(none)'} session: ${r.session_key}`)) + if (!sid) { return true } - rpc('session.title', { session_id: sid, title: arg }) - .then(() => sys(`title → ${arg}`)) + + if (!arg) { + rpc('session.title', { session_id: sid }).then((r: any) => + sys(`title: ${r.title || '(none)'} session: ${r.session_key}`) + ) + + return true + } + + rpc('session.title', { session_id: sid, title: arg }).then(() => sys(`title → ${arg}`)) + return true case 'tools': if (!info?.tools || !Object.keys(info.tools).length) { sys('no tools loaded') + return true } + sys( Object.entries(info.tools) .map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`) .join('\n') ) + return true case 'skills': if (!arg || arg === 'list') { 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')) + + sys( + Object.entries(info.skills) + .map(([k, vs]) => `${k}: ${vs.join(', ')}`) + .join('\n') + ) + return true } + if (arg.startsWith('search ')) { - rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }) - .then((r: any) => { - if (!r.results?.length) { sys('no results'); return } - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) + rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }).then((r: any) => { + if (!r.results?.length) { + sys('no results') + + return + } + + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true } + if (arg.startsWith('install ')) { - rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }) - .then((r: any) => sys(r.installed ? `installed ${r.name}` : 'install failed')) + rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }).then((r: any) => + sys(r.installed ? `installed ${r.name}` : 'install failed') + ) + return true } + if (arg === 'browse' || arg.startsWith('browse ')) { - rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }) - .then((r: any) => { - if (!r.results?.length) { sys('no skills available'); return } - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) + rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }).then((r: any) => { + if (!r.results?.length) { + sys('no skills available') + + return + } + + sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) + }) + return true } + if (arg.startsWith('inspect ')) { - rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }) - .then((r: any) => sys(JSON.stringify(r.info, null, 2))) + rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }).then((r: any) => + sys(JSON.stringify(r.info, null, 2)) + ) + return true } + sys('usage: /skills [list|search |install |browse|inspect ]') + return true case 'verbose': - rpc('config.set', { key: 'verbose', value: arg || 'cycle' }) - .then((r: any) => sys(`verbose → ${r.value}`)) + rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose → ${r.value}`)) + return true case 'yolo': - rpc('config.set', { key: 'yolo', value: '' }) - .then((r: any) => sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`)) + rpc('config.set', { key: 'yolo', value: '' }).then((r: any) => + sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`) + ) + return true case 'reasoning': if (!arg) { sys('usage: /reasoning ') + return true } - rpc('config.set', { key: 'reasoning', value: arg }) - .then((r: any) => sys(`reasoning → ${r.value}`)) + + rpc('config.set', { key: 'reasoning', value: arg }).then((r: any) => sys(`reasoning → ${r.value}`)) + return true case 'stop': - rpc('process.stop') - .then((r: any) => sys(`killed ${r.killed} process(es)`)) + rpc('process.stop').then((r: any) => sys(`killed ${r.killed} process(es)`)) + return true case 'profile': gw.request('config.get', { key: 'profile' }) .then((r: any) => sys(`profile: ${r.display}`)) .catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`)) + return true case 'save': - if (!sid) return true - rpc('session.save', { session_id: sid }) - .then((r: any) => sys(`saved to ${r.file}`)) + if (!sid) { + return true + } + + rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved to ${r.file}`)) + return true case 'provider': - rpc('config.get', { key: 'provider' }) - .then((r: any) => { - const lines = [`model: ${r.model} provider: ${r.provider}`] - if (r.providers?.length) lines.push(`available: ${r.providers.join(', ')}`) - sys(lines.join('\n')) - }) + rpc('config.get', { key: 'provider' }).then((r: any) => { + const lines = [`model: ${r.model} provider: ${r.provider}`] + + if (r.providers?.length) { + lines.push(`available: ${r.providers.join(', ')}`) + } + + sys(lines.join('\n')) + }) + return true case 'prompt': if (!arg) { - rpc('config.get', { key: 'prompt' }) - .then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) + rpc('config.get', { key: 'prompt' }).then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) + return true } - rpc('config.set', { key: 'prompt', value: arg }) - .then((r: any) => sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared')) + + rpc('config.set', { key: 'prompt', value: arg }).then((r: any) => + sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared') + ) + return true case 'personality': if (!arg) { sys('usage: /personality (concise, creative, analytical, friendly, none)') + return true } - rpc('config.set', { key: 'personality', value: arg }) - .then((r: any) => sys(`personality → ${r.value || 'default'}`)) + + rpc('config.set', { key: 'personality', value: arg }).then((r: any) => + sys(`personality → ${r.value || 'default'}`) + ) + return true case 'plan': send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.') + return true case 'background': + case 'bg': if (!arg) { sys('usage: /background ') + return true } - rpc('prompt.background', { session_id: sid, text: arg }) - .then((r: any) => sys(`background task ${r.task_id} started`)) + + rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => + sys(`background task ${r.task_id} started`) + ) + return true case 'btw': if (!arg) { sys('usage: /btw ') + return true } - rpc('prompt.btw', { session_id: sid, text: arg }) - .then(() => sys('btw running…')) + + rpc('prompt.btw', { session_id: sid, text: arg }).then(() => sys('btw running…')) + return true case 'queue': if (!arg) { sys(`${queueRef.current.length} queued message(s)`) + return true } + enqueue(arg) sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + return true case 'rollback': - if (!sid) return true - if (!arg) { - rpc('rollback.list', { session_id: sid }) - .then((r: any) => { - if (!r.enabled) { sys('checkpoints not enabled — use hermes --checkpoints'); return } - if (!r.checkpoints?.length) { sys('no checkpoints'); return } - sys(r.checkpoints.map((c: any, i: number) => - ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})` - ).join('\n')) - }) + if (!sid) { return true } + + if (!arg) { + rpc('rollback.list', { session_id: sid }).then((r: any) => { + if (!r.enabled) { + sys('checkpoints not enabled — use hermes --checkpoints') + + return + } + + if (!r.checkpoints?.length) { + sys('no checkpoints') + + return + } + + sys( + r.checkpoints + .map((c: any, i: number) => ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})`) + .join('\n') + ) + }) + + return true + } + if (arg.startsWith('diff ')) { const ref = arg.slice(5).trim() rpc('rollback.list', { session_id: sid }).then((r: any) => { const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - if (!hash) { sys(`checkpoint ${ref} not found`); return } - rpc('rollback.diff', { session_id: sid, hash }) - .then((d: any) => sys(d.stat || d.diff || 'no changes')) + + if (!hash) { + sys(`checkpoint ${ref} not found`) + + return + } + + rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => sys(d.stat || d.diff || 'no changes')) }) + return true } + { const parts = arg.trim().split(/\s+/) const ref = parts[0]! const file = parts[1] rpc('rollback.list', { session_id: sid }).then((r: any) => { const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - if (!hash) { sys(`checkpoint ${ref} not found`); return } - rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }) - .then((d: any) => sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`)) + + if (!hash) { + sys(`checkpoint ${ref} not found`) + + return + } + + rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }).then((d: any) => + sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`) + ) }) } + return true case 'insights': - rpc('insights.get', { days: arg ? parseInt(arg) : 30 }) - .then((r: any) => sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`)) + rpc('insights.get', { days: arg ? parseInt(arg) : 30 }).then((r: any) => + sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) + ) + return true case 'toolsets': if (!info?.tools) { sys('no toolsets loaded') + return true } - sys(Object.entries(info.tools).map(([k, vs]) => `${k}: ${vs.length} tools`).join('\n')) + + sys( + Object.entries(info.tools) + .map(([k, vs]) => `${k}: ${vs.length} tools`) + .join('\n') + ) + return true case 'paste': - sys('clipboard paste: use your terminal\'s paste shortcut (images not yet supported in TUI)') + sys("clipboard paste: use your terminal's paste shortcut (images not yet supported in TUI)") + return true case 'reload-mcp': + case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }) - .then(() => sys('MCP servers reloaded')) + rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP servers reloaded')) + return true case 'browser': if (!arg || arg === 'status') { - rpc('browser.manage', { action: 'status' }) - .then((r: any) => sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected')) + rpc('browser.manage', { action: 'status' }).then((r: any) => + sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected') + ) } else if (arg === 'connect' || arg.startsWith('connect ')) { const url = arg.split(/\s+/)[1] - rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }) - .then((r: any) => sys(`browser connected: ${r.url}`)) + rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }).then((r: any) => + sys(`browser connected: ${r.url}`) + ) } else if (arg === 'disconnect') { - rpc('browser.manage', { action: 'disconnect' }) - .then(() => sys('browser disconnected')) + rpc('browser.manage', { action: 'disconnect' }).then(() => sys('browser disconnected')) } else { sys('usage: /browser [connect|disconnect|status]') } + return true case 'platforms': + case 'gateway': sys('gateway status is not available in TUI mode') + return true case 'statusbar': + case 'sb': setStatusBar(v => !v) sys(`status bar ${statusBar ? 'off' : 'on'}`) + return true case 'voice': if (!arg || arg === 'status') { - rpc('voice.toggle', { action: 'status' }) - .then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) + rpc('voice.toggle', { action: 'status' }).then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) } else if (arg === 'on' || arg === 'off') { - rpc('voice.toggle', { action: arg }) - .then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) + rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) } else if (arg === 'record') { - rpc('voice.record', { action: 'start' }) - .then(() => sys('recording… (use /voice stop to transcribe)')) + rpc('voice.record', { action: 'start' }).then(() => sys('recording… (use /voice stop to transcribe)')) } else if (arg === 'stop') { - rpc('voice.record', { action: 'stop' }) - .then((r: any) => { - if (r.text) { send(r.text) } else { sys('no speech detected') } - }) + rpc('voice.record', { action: 'stop' }).then((r: any) => { + if (r.text) { + send(r.text) + } else { + sys('no speech detected') + } + }) } else if (arg === 'tts') { const last = messages.filter(m => m.role === 'assistant').at(-1) + if (last) { - rpc('voice.tts', { text: last.text }) - .then(() => sys('speaking…')) + rpc('voice.tts', { text: last.text }).then(() => sys('speaking…')) } else { sys('no response to speak') } } else { sys('usage: /voice [on|off|status|record|stop|tts]') } + return true case 'plugins': - rpc('plugins.list') - .then((r: any) => { - if (!r.plugins?.length) { sys('no plugins installed'); return } - sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) - }) + rpc('plugins.list').then((r: any) => { + if (!r.plugins?.length) { + sys('no plugins installed') + + return + } + + sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) + }) + return true case 'cron': if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }) - .then((r: any) => { - const jobs = r.jobs || r.schedules || [] - if (!jobs.length) { sys('no cron jobs'); return } - sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) - }) + rpc('cron.manage', { action: 'list' }).then((r: any) => { + const jobs = r.jobs || r.schedules || [] + + if (!jobs.length) { + sys('no cron jobs') + + return + } + + sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) + }) } else { const parts = arg.split(/\s+/) const sub = parts[0]! + if (sub === 'add' || sub === 'create') { const name = parts[1] || '' const schedule = parts[2] || '' const prompt = parts.slice(3).join(' ') - rpc('cron.manage', { action: 'add', name, schedule, prompt }) - .then((r: any) => sys(r.message || r.status || 'created')) + rpc('cron.manage', { action: 'add', name, schedule, prompt }).then((r: any) => + sys(r.message || r.status || 'created') + ) } else { - rpc('cron.manage', { action: sub, name: parts[1] || '' }) - .then((r: any) => sys(r.message || r.status || JSON.stringify(r))) + rpc('cron.manage', { action: sub, name: parts[1] || '' }).then((r: any) => + sys(r.message || r.status || JSON.stringify(r)) + ) } } + return true case 'update': sys('update not available in TUI mode — run: pip install -U hermes-agent') + + return true + + case 'hermes': + if (!arg) { + sys( + 'usage: /hermes non-interactive `hermes` CLI (e.g. sessions list, chat -q "hi"). Interactive setup/browse/edit must run in a separate terminal.' + ) + + return true + } + + rpc('cli.exec', { argv: arg.split(/\s+/).filter(Boolean) }) + .then((r: any) => { + if (r.blocked) { + sys(r.hint ?? 'blocked') + + return + } + + sys(r.output ?? '(no output)') + + if (r.code !== 0) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + return true case 'model': @@ -1017,8 +1249,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('config.set', { key: 'model', value: arg }) - .then(() => sys(`model → ${arg}`)) + rpc('config.set', { key: 'model', value: arg }).then(() => sys(`model → ${arg}`)) return true @@ -1029,8 +1260,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('config.set', { key: 'skin', value: arg }) - .then(() => sys(`skin → ${arg} (restart to apply)`)) + rpc('config.set', { key: 'skin', value: arg }).then(() => sys(`skin → ${arg} (restart to apply)`)) return true @@ -1060,11 +1290,12 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => sys(`unknown command: /${name}`)) }) + return true } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar] + [catalog, compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar] ) const submit = useCallback( @@ -1320,7 +1551,7 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} - {!blocked && input.startsWith('/') && } + {!blocked && input.startsWith('/') && } diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx index be7f755d04..810dc01007 100644 --- a/ui-tui/src/components/commandPalette.tsx +++ b/ui-tui/src/components/commandPalette.tsx @@ -1,23 +1,21 @@ import { Box, Text } from 'ink' -import { COMMANDS } from '../constants.js' import type { Theme } from '../theme.js' -export function CommandPalette({ filter, t }: { filter: string; t: Theme }) { - const matches = COMMANDS.filter(([cmd]) => cmd.startsWith(filter)) - +export function CommandPalette({ matches, t }: { matches: [string, string][]; t: Theme }) { if (!matches.length) { return null } return ( - - {matches.map(([cmd, desc]) => ( - + + {matches.map(([cmd, desc], i) => ( + {cmd} - — {desc} + + {desc ? — {desc} : null} ))} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index adee83ad28..7b1d157755 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -147,37 +147,47 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st if (line.match(/^>\s?/)) { const quoteLines: string[] = [] + while (i < lines.length && lines[i]!.match(/^>\s?/)) { quoteLines.push(lines[i]!.replace(/^>\s?/, '')) i++ } + nodes.push( {quoteLines.map((ql, qi) => ( - {' │ '} + {' │ '} + ))} ) + continue } if (line.includes('|') && line.trim().startsWith('|')) { const tableRows: string[][] = [] + while (i < lines.length && lines[i]!.trim().startsWith('|')) { const row = lines[i]!.trim() + if (!/^[|\s:-]+$/.test(row)) { tableRows.push( - row.split('|').filter(Boolean).map(c => c.trim()) + row + .split('|') + .filter(Boolean) + .map(c => c.trim()) ) } + i++ } + if (tableRows.length) { - const widths = tableRows[0]!.map((_, ci) => - Math.max(...tableRows.map(r => (r[ci] ?? '').length)) - ) + const widths = tableRows[0]!.map((_, ci) => Math.max(...tableRows.map(r => (r[ci] ?? '').length))) + nodes.push( {tableRows.map((row, ri) => ( @@ -188,6 +198,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st ) } + continue } diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index 96fe21e1c8..f2e8d95ce7 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -5,7 +5,11 @@ import { useState } from 'react' import type { Theme } from '../theme.js' export function MaskedPrompt({ - icon, label, onSubmit, sub, t + icon, + label, + onSubmit, + sub, + t }: { icon: string label: string @@ -17,8 +21,10 @@ export function MaskedPrompt({ return ( - {icon} {label} - {sub && {sub}} + + {icon} {label} + + {sub && {sub}} {'> '} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index baa8ad25b2..9b5750b093 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -14,8 +14,15 @@ interface SessionItem { function age(ts: number): string { const d = (Date.now() / 1000 - ts) / 86400 - if (d < 1) return 'today' - if (d < 2) return 'yesterday' + + if (d < 1) { + return 'today' + } + + if (d < 2) { + return 'yesterday' + } + return `${Math.floor(d)}d ago` } @@ -46,16 +53,32 @@ export function SessionPicker({ }, [gw]) useInput((ch, key) => { - if (key.escape) return onCancel() - if (key.upArrow && sel > 0) setSel(s => s - 1) - if (key.downArrow && sel < items.length - 1) setSel(s => s + 1) - if (key.return && items[sel]) onSelect(items[sel]!.id) + if (key.escape) { + return onCancel() + } + + if (key.upArrow && sel > 0) { + setSel(s => s - 1) + } + + if (key.downArrow && sel < items.length - 1) { + setSel(s => s + 1) + } + + if (key.return && items[sel]) { + onSelect(items[sel]!.id) + } const n = parseInt(ch) - if (n >= 1 && n <= Math.min(9, items.length)) onSelect(items[n - 1]!.id) + + if (n >= 1 && n <= Math.min(9, items.length)) { + onSelect(items[n - 1]!.id) + } }) - if (loading) return loading sessions… + if (loading) { + return loading sessions… + } if (!items.length) { return ( @@ -71,10 +94,13 @@ export function SessionPicker({ return ( - Resume Session - {off > 0 && ↑ {off} more} + + Resume Session + + {off > 0 && ↑ {off} more} {visible.map((s, vi) => { const i = off + vi + return ( {sel === i ? '▸ ' : ' '} @@ -82,12 +108,13 @@ export function SessionPicker({ {i + 1}. {s.title || s.preview || s.id.slice(0, 8)} - {' '}({s.message_count} msgs, {age(s.started_at)}) + {' '} + ({s.message_count} msgs, {age(s.started_at)}) ) })} - {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} + {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} ↑/↓ select · Enter resume · 1-9 quick · Esc cancel ) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index f5e7351fad..8d1fbde210 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -6,7 +6,17 @@ import { pick } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -export function Thinking({ reasoning, t, thinking, tools }: { reasoning: string; t: Theme; thinking?: string; tools: ActiveTool[] }) { +export function Thinking({ + reasoning, + t, + thinking, + tools +}: { + reasoning: string + t: Theme + thinking?: string + tools: ActiveTool[] +}) { const [frame, setFrame] = useState(0) const [verb] = useState(() => pick(VERBS)) const [face] = useState(() => pick(FACES)) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 5b921807b0..f638b3f435 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -1,50 +1,6 @@ import type { Theme } from './theme.js' import type { Role, Usage } from './types.js' -export const COMMANDS: [string, string][] = [ - ['/help', 'commands & hotkeys'], - ['/new', 'new session'], - ['/resume', 'resume a previous session'], - ['/title', 'set session title'], - ['/history', 'show session list'], - ['/clear', 'reset session + chat'], - ['/undo', 'drop last exchange'], - ['/retry', 'resend last message'], - ['/save', 'save conversation to file'], - ['/compact', 'toggle compact [focus]'], - ['/compress', 'compress context'], - ['/model', 'switch model'], - ['/skin', 'change theme'], - ['/provider', 'show model/provider info'], - ['/prompt', 'set custom system prompt'], - ['/personality', 'set personality preset'], - ['/verbose', 'cycle tool verbosity'], - ['/yolo', 'toggle auto-approve mode'], - ['/reasoning', 'set reasoning level'], - ['/tools', 'list active tools'], - ['/toolsets', 'list toolsets'], - ['/skills', 'list skills'], - ['/stop', 'kill background processes'], - ['/background', 'run prompt in background'], - ['/btw', 'side question (no tools)'], - ['/plan', 'invoke plan skill'], - ['/queue', 'queue prompt for next turn'], - ['/profile', 'show active profile'], - ['/cost', 'token usage stats'], - ['/context', 'context window info'], - ['/insights', 'usage analytics'], - ['/copy', 'copy last response'], - ['/paste', 'clipboard info'], - ['/config', 'show config'], - ['/status', 'session info'], - ['/statusbar', 'toggle status bar'], - ['/voice', 'voice mode toggle'], - ['/reload-mcp', 'reload MCP servers'], - ['/rollback', 'checkpoint info'], - ['/browser', 'browser tools info'], - ['/quit', 'exit hermes'] -] - export const FACES = [ '(。•́︿•̀。)', '(◔_◔)', @@ -67,6 +23,7 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+C', 'interrupt / clear / exit'], ['Ctrl+D', 'exit'], ['Ctrl+L', 'clear screen'], + ['Tab', 'complete /commands (registry-aware)'], ['↑/↓', 'queue edit (if queued) / input history'], ['PgUp/PgDn', 'scroll messages'], ['Esc', 'clear input'], diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index cc0a15b4c9..b8c247d97a 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,5 +1,5 @@ -import React from 'react' import { render } from 'ink' +import React from 'react' import { App } from './app.js' import { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index b77ba44439..6300cef3a7 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'node:fs' +import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { join } from 'node:path' @@ -17,34 +17,51 @@ function decode(s: string): string { } export function load(): string[] { - if (cache) return cache + if (cache) { + return cache + } + try { if (existsSync(file)) { - cache = readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map(decode) - .slice(-MAX) + cache = readFileSync(file, 'utf8').split('\n').filter(Boolean).map(decode).slice(-MAX) } else { cache = [] } } catch { cache = [] } + return cache } export function append(line: string): void { const trimmed = line.trim() - if (!trimmed) return + + if (!trimmed) { + return + } + const items = load() - if (items.at(-1) === trimmed) return + + if (items.at(-1) === trimmed) { + return + } + items.push(trimmed) - if (items.length > MAX) items.splice(0, items.length - MAX) + + if (items.length > MAX) { + items.splice(0, items.length - MAX) + } + try { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } + appendFileSync(file, encode(trimmed) + '\n') - } catch { /* ignore */ } + } catch { + /* ignore */ + } } export function all(): string[] { diff --git a/ui-tui/src/lib/slash.ts b/ui-tui/src/lib/slash.ts new file mode 100644 index 0000000000..07b106c7d1 --- /dev/null +++ b/ui-tui/src/lib/slash.ts @@ -0,0 +1,124 @@ +import type { SlashCatalog } from '../types.js' + +/** Match SlashCommandCompleter: command names, subcommands, then skills. */ +export function paletteForLine(line: string, c: SlashCatalog | null): [string, string][] { + if (!c || !line.startsWith('/')) { + return [] + } + + const parts = line.split(/\s+/) + const baseRaw = parts[0]! + const base = baseRaw.toLowerCase() + const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) + + if (inSub) { + const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' + + if (subText.includes(' ') || parts.length > 2) { + return [] + } + + const head = subText.split(/\s+/)[0] ?? '' + + if (subText.includes(' ') && head !== subText) { + return [] + } + + const canonical = c.canon[base] ?? baseRaw + const subs = c.sub[canonical] + + if (!subs?.length) { + return [] + } + + const lo = head.toLowerCase() + + return subs + .filter(s => s.toLowerCase().startsWith(lo) && s.toLowerCase() !== lo) + .slice(0, 14) + .map(s => [s, '']) + } + + const word = line.slice(1) + + return c.pairs + .filter(([k]) => k.slice(1).startsWith(word)) + .slice(0, 16) + .map(([k, d]) => [k, d]) +} + +/** Tab: longest common prefix of palette matches, or first unique completion + space. */ +export function tabAdvance(line: string, c: SlashCatalog | null): string | null { + if (!c || !line.startsWith('/')) { + return null + } + + const rows = paletteForLine(line, c) + + if (!rows.length) { + return null + } + + const parts = line.split(/\s+/) + const baseRaw = parts[0]! + const base = baseRaw.toLowerCase() + const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) + + if (inSub) { + const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' + const head = subText.split(/\s+/)[0] ?? '' + const picks = rows.map(([s]) => s) + + if (picks.length === 1) { + return `${baseRaw} ${picks[0]!} ` + } + + const cp = commonPrefix(picks) + + if (cp.length > head.length) { + return `${baseRaw} ${cp}` + } + + return null + } + + const word = line.slice(1) + const names = rows.map(([k]) => k.slice(1)) + const cp = commonPrefix(names) + + if (names.length === 1) { + return `/${names[0]!} ` + } + + if (cp.length > word.length) { + return `/${cp}` + } + + return null +} + +function commonPrefix(xs: string[]): string { + if (!xs.length) { + return '' + } + + let n = 0 + + outer: while (true) { + const ch = xs[0]![n] + + if (ch === undefined) { + break + } + + for (const x of xs) { + if (x[n] !== ch) { + break outer + } + } + + n++ + } + + return xs[0]!.slice(0, n) +} diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx index cc0a15b4c9..b8c247d97a 100644 --- a/ui-tui/src/main.tsx +++ b/ui-tui/src/main.tsx @@ -1,5 +1,5 @@ -import React from 'react' import { render } from 'ink' +import React from 'react' import { App } from './app.js' import { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 253c46069c..4e3bfce2d9 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -43,3 +43,10 @@ export interface SecretReq { prompt: string requestId: string } + +/** From `commands.catalog` — mirrors hermes_cli.commands COMMANDS + SUBCOMMANDS + skills. */ +export interface SlashCatalog { + canon: Record + pairs: [string, string][] + sub: Record +} From 2893e9df71f7430920d7c59c3e61a8c255d9de5d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 4 Apr 2026 13:00:55 -0500 Subject: [PATCH 009/157] feat: add image pasting capability --- tui_gateway/server.py | 70 +++++++++++++++++++++++++++++++++++++++-- ui-tui/src/app.tsx | 42 ++++++++++++++++--------- ui-tui/src/constants.ts | 1 + 3 files changed, 97 insertions(+), 16 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c0e9849ae8..84c86a054b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -195,7 +195,13 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): def _init_session(sid: str, key: str, agent, history: list): - _sessions[sid] = {"agent": agent, "session_key": key, "history": history} + _sessions[sid] = { + "agent": agent, + "session_key": key, + "history": history, + "attached_images": [], + "image_counter": 0, + } try: from tools.approval import register_gateway_notify, load_permanent_allowlist register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) @@ -210,6 +216,38 @@ def _with_checkpoints(session, fn): return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) +def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: + """Pre-analyze attached images via vision and prepend descriptions to user text.""" + import asyncio, json as _json + from tools.vision_tools import vision_analyze_tool + + prompt = ( + "Describe everything visible in this image in thorough detail. " + "Include any text, code, data, objects, people, layout, colors, " + "and any other notable visual information." + ) + + parts: list[str] = [] + for path in image_paths: + p = Path(path) + if not p.exists(): + continue + hint = f"[You can examine it with vision_analyze using image_url: {p}]" + try: + r = _json.loads(asyncio.run(vision_analyze_tool(image_url=str(p), user_prompt=prompt))) + desc = r.get("analysis", "") if r.get("success") else None + parts.append(f"[The user attached an image:\n{desc}]\n{hint}" if desc + else f"[The user attached an image but analysis failed.]\n{hint}") + except Exception: + parts.append(f"[The user attached an image but analysis failed.]\n{hint}") + + text = user_text or "" + prefix = "\n\n".join(parts) + if prefix: + return f"{prefix}\n\n{text}" if text else prefix + return text or "What do you see in this image?" + + # ── Methods: session ───────────────────────────────────────────────── @method("session.create") @@ -367,8 +405,10 @@ def _(rid, params: dict) -> dict: def run(): try: + images = session.pop("attached_images", []) + prompt = _enrich_with_attached_images(text, images) if images else text result = agent.run_conversation( - text, conversation_history=list(history), + prompt, conversation_history=list(history), stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), ) if isinstance(result, dict): @@ -385,6 +425,32 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "streaming"}) +@method("clipboard.paste") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + from datetime import datetime + from hermes_cli.clipboard import has_clipboard_image, save_clipboard_image + except Exception as e: + return _err(rid, 5027, f"clipboard unavailable: {e}") + + if not has_clipboard_image(): + return _ok(rid, {"attached": False, "message": "No image found in clipboard"}) + + img_dir = _hermes_home / "images" + img_dir.mkdir(parents=True, exist_ok=True) + session["image_counter"] = session.get("image_counter", 0) + 1 + img_path = img_dir / f"clip_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}.png" + + if not save_clipboard_image(img_path): + return _ok(rid, {"attached": False, "message": "Clipboard has image but extraction failed"}) + + session.setdefault("attached_images", []).append(str(img_path)) + return _ok(rid, {"attached": True, "path": str(img_path), "count": len(session["attached_images"])}) + + @method("prompt.background") def _(rid, params: dict) -> dict: text, parent = params.get("text", ""), params.get("session_id", "") diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 774e47948a..dacfc95185 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -251,6 +251,11 @@ export function App({ gw }: { gw: GatewayClient }) { }) } + const paste = () => + rpc('clipboard.paste', { session_id: sid }).then((r: any) => + sys(r.attached ? `📎 image #${r.count} attached` : r.message || 'no image in clipboard') + ) + const interpolate = (text: string, then: (result: string) => void) => { setStatus('interpolating…') const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] @@ -387,6 +392,10 @@ export function App({ gw }: { gw: GatewayClient }) { setMessages([]) } + if (key.ctrl && ch === 'v') { + return paste() + } + if (key.escape) { clearIn() } @@ -1091,7 +1100,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'paste': - sys("clipboard paste: use your terminal's paste shortcut (images not yet supported in TUI)") + paste() return true @@ -1211,27 +1220,25 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'update': - sys('update not available in TUI mode — run: pip install -U hermes-agent') + case 'hermes': { + const argv = name === 'update' ? ['update'] : arg.split(/\s+/).filter(Boolean) - return true - - case 'hermes': - if (!arg) { - sys( - 'usage: /hermes non-interactive `hermes` CLI (e.g. sessions list, chat -q "hi"). Interactive setup/browse/edit must run in a separate terminal.' - ) + if (!argv.length) { + sys('usage: /hermes (e.g. sessions list, chat -q "hi")') return true } - rpc('cli.exec', { argv: arg.split(/\s+/).filter(Boolean) }) + if (name === 'update') { + setBusy(true) + setStatus('updating…') + } + + rpc('cli.exec', { argv, timeout: name === 'update' ? 600 : 240 }) .then((r: any) => { if (r.blocked) { - sys(r.hint ?? 'blocked') - - return + return sys(r.hint ?? 'blocked') } - sys(r.output ?? '(no output)') if (r.code !== 0) { @@ -1239,8 +1246,15 @@ export function App({ gw }: { gw: GatewayClient }) { } }) .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => { + if (name === 'update') { + setStatus('ready') + setBusy(false) + } + }) return true + } case 'model': if (!arg) { diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index f638b3f435..87c1fdac27 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -23,6 +23,7 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+C', 'interrupt / clear / exit'], ['Ctrl+D', 'exit'], ['Ctrl+L', 'clear screen'], + ['Ctrl+V', 'paste clipboard image (same as /paste)'], ['Tab', 'complete /commands (registry-aware)'], ['↑/↓', 'queue edit (if queued) / input history'], ['PgUp/PgDn', 'scroll messages'], From f116c59071773ae62d34f4fb8e8c7261079eafcf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 5 Apr 2026 18:50:41 -0500 Subject: [PATCH 010/157] tui: inherit Python-side rendering via gateway bridge --- tui_gateway/render.py | 49 +++++++++++++++++++++++++++ tui_gateway/server.py | 48 ++++++++++++++++++++++---- ui-tui/src/app.tsx | 30 +++++++++++++--- ui-tui/src/components/messageLine.tsx | 6 +++- ui-tui/src/lib/text.ts | 9 ++++- 5 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 tui_gateway/render.py diff --git a/tui_gateway/render.py b/tui_gateway/render.py new file mode 100644 index 0000000000..c15ddef7c0 --- /dev/null +++ b/tui_gateway/render.py @@ -0,0 +1,49 @@ +"""Rendering bridge — routes TUI content through Python-side renderers. + +When agent.rich_output exists, its functions are used. When it doesn't, +everything returns None and the TUI falls back to its own markdown.tsx. +""" + +from __future__ import annotations + + +def render_message(text: str, cols: int = 80) -> str | None: + try: + from agent.rich_output import format_response + except ImportError: + return None + + try: + return format_response(text, cols=cols) + except TypeError: + return format_response(text) + except Exception: + return None + + +def render_diff(text: str, cols: int = 80) -> str | None: + try: + from agent.rich_output import render_diff as _rd + except ImportError: + return None + + try: + return _rd(text, cols=cols) + except TypeError: + return _rd(text) + except Exception: + return None + + +def make_stream_renderer(cols: int = 80): + try: + from agent.rich_output import StreamingRenderer + except ImportError: + return None + + try: + return StreamingRenderer(cols=cols) + except TypeError: + return StreamingRenderer() + except Exception: + return None diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 84c86a054b..d788b12a38 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12,6 +12,8 @@ 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") +from tui_gateway.render import make_stream_renderer, render_diff, render_message + _sessions: dict[str, dict] = {} _methods: dict[str, callable] = {} _pending: dict[str, threading.Event] = {} @@ -194,13 +196,14 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): ) -def _init_session(sid: str, key: str, agent, history: list): +def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): _sessions[sid] = { "agent": agent, "session_key": key, "history": history, "attached_images": [], "image_counter": 0, + "cols": cols, } try: from tools.approval import register_gateway_notify, load_permanent_allowlist @@ -259,7 +262,7 @@ def _(rid, params: dict) -> dict: try: agent = _make_agent(sid, key) _get_db().create_session(key, source="tui", model=_resolve_model()) - _init_session(sid, key, agent, []) + _init_session(sid, key, agent, [], cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"agent init failed: {e}") return _ok(rid, {"session_id": sid}) @@ -300,7 +303,7 @@ def _(rid, params: dict) -> dict: for m in db.get_messages(target) if m.get("role") in ("user", "assistant", "tool", "system")] agent = _make_agent(sid, target, session_id=target) - _init_session(sid, target, agent, history) + _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history)}) @@ -392,6 +395,15 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"status": "interrupted"}) +@method("terminal.resize") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + session["cols"] = int(params.get("cols", 80)) + return _ok(rid, {"cols": session["cols"]}) + + # ── Methods: prompt ────────────────────────────────────────────────── @method("prompt.submit") @@ -405,19 +417,36 @@ def _(rid, params: dict) -> dict: def run(): try: + cols = session.get("cols", 80) + streamer = make_stream_renderer(cols) images = session.pop("attached_images", []) prompt = _enrich_with_attached_images(text, images) if images else text + + def _stream(delta): + payload = {"text": delta} + if streamer and (r := streamer.feed(delta)) is not None: + payload["rendered"] = r + _emit("message.delta", sid, payload) + result = agent.run_conversation( prompt, conversation_history=list(history), - stream_callback=lambda delta: _emit("message.delta", sid, {"text": delta}), + stream_callback=_stream, ) + if isinstance(result, dict): if isinstance(result.get("messages"), list): session["history"] = result["messages"] + raw = result.get("final_response", "") status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" - _emit("message.complete", sid, {"text": result.get("final_response", ""), "usage": _get_usage(agent), "status": status}) else: - _emit("message.complete", sid, {"text": str(result), "usage": _get_usage(agent), "status": "complete"}) + raw = str(result) + status = "complete" + + payload = {"text": raw, "usage": _get_usage(agent), "status": status} + rendered = render_message(raw, cols) + if rendered: + payload["rendered"] = rendered + _emit("message.complete", sid, payload) except Exception as e: _emit("error", sid, {"message": str(e)}) @@ -868,7 +897,12 @@ def _(rid, params: dict) -> dict: return _err(rid, 4014, "hash required") try: r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target)) - return _ok(rid, {"stat": r.get("stat", ""), "diff": r.get("diff", "")[:4000]}) + raw = r.get("diff", "")[:4000] + payload = {"stat": r.get("stat", ""), "diff": raw} + rendered = render_diff(raw, session.get("cols", 80)) + if rendered: + payload["rendered"] = rendered + return _ok(rid, payload) except Exception as e: return _err(rid, 5022, str(e)) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index dacfc95185..f170ea8cf9 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -117,6 +117,19 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [messages.length]) + useEffect(() => { + if (!sid || !stdout) { + return + } + + const onResize = () => rpc('terminal.resize', { session_id: sid, cols: stdout.columns ?? 80 }) + stdout.on('resize', onResize) + + return () => { + stdout.off('resize', onResize) + } + }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps + const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2) const viewport = useMemo(() => { @@ -144,6 +157,10 @@ export function App({ gw }: { gw: GatewayClient }) { start = end - 1 } + if (start > 0 && messages[start - 1]?.role === 'user') { + start-- + } + return { above: start, end, start } }, [cols, messages, msgBudget, scrollOffset]) @@ -155,7 +172,7 @@ export function App({ gw }: { gw: GatewayClient }) { }) const newSession = (msg?: string) => - rpc('session.create').then((r: any) => { + rpc('session.create', { cols }).then((r: any) => { if (!r) { return } @@ -534,7 +551,7 @@ export function App({ gw }: { gw: GatewayClient }) { break } - buf.current += p.text + buf.current += p.rendered ?? p.text setThinking(false) setTools([]) setReasoning('') @@ -543,7 +560,7 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.complete': { idle() - setMessages(prev => upsert(prev, 'assistant', (p?.text ?? buf.current).trimStart())) + setMessages(prev => upsert(prev, 'assistant', (p?.rendered ?? p?.text ?? buf.current).trimStart())) buf.current = '' setStatus('ready') @@ -1050,7 +1067,9 @@ export function App({ gw }: { gw: GatewayClient }) { return } - rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => sys(d.stat || d.diff || 'no changes')) + rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => + sys(d.rendered || d.stat || d.diff || 'no changes') + ) }) return true @@ -1239,6 +1258,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (r.blocked) { return sys(r.hint ?? 'blocked') } + sys(r.output ?? '(no output)') if (r.code !== 0) { @@ -1548,7 +1568,7 @@ export function App({ gw }: { gw: GatewayClient }) { onSelect={id => { setPicker(false) setStatus('resuming…') - gw.request('session.resume', { session_id: id }) + gw.request('session.resume', { session_id: id, cols }) .then((r: any) => { setSid(r.session_id) setMessages([]) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 36d86acc70..b2e8c914e6 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,7 +1,7 @@ import { Box, Text } from 'ink' import { LONG_MSG, ROLE } from '../constants.js' -import { userDisplay } from '../lib/text.js' +import { hasAnsi, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg } from '../types.js' @@ -12,6 +12,10 @@ export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; const content = (() => { if (msg.role === 'assistant') { + if (hasAnsi(msg.text)) { + return {msg.text} + } + return } diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 68aa468c10..a441841c28 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,5 +1,12 @@ import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\x1b\[[0-9;]*m/g + +export const stripAnsi = (s: string) => s.replace(ANSI_RE, '') + +export const hasAnsi = (s: string) => s.includes('\x1b[') + export const compactPreview = (s: string, max: number) => { const one = s.replace(/\s+/g, ' ').trim() @@ -7,7 +14,7 @@ export const compactPreview = (s: string, max: number) => { } export 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) + text.split('\n').reduce((sum, line) => sum + Math.ceil((stripAnsi(line).length || 1) / w), 0) export const flat = (r: Record) => Object.values(r).flat() From 4c7d5ec778b776b74a32657ff3be35d73522bfad Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 5 Apr 2026 18:55:59 -0500 Subject: [PATCH 011/157] tui: add tui arg --- hermes_cli/main.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fb0cf0a85a..0e1a46df4e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -546,8 +546,23 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: return None +def _launch_tui(): + """Replace current process with the Ink TUI.""" + tui_dir = PROJECT_ROOT / "ui-tui" + + if not (tui_dir / "node_modules").exists(): + print("TUI dependencies not installed.") + print(f" cd {tui_dir} && npm install") + sys.exit(1) + + sys.exit(subprocess.call(["npm", "start"], cwd=str(tui_dir))) + + def cmd_chat(args): """Run interactive chat CLI.""" + if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": + _launch_tui() + # Resolve --continue into --resume with the latest CLI session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): @@ -4078,6 +4093,12 @@ For more help on a command: default=False, help="Include the session ID in the agent's system prompt" ) + parser.add_argument( + "--tui", + action="store_true", + default=False, + help="Launch the Ink-based terminal UI instead of the classic REPL" + ) subparsers = parser.add_subparsers(dest="command", help="Command to run") @@ -4174,6 +4195,12 @@ For more help on a command: default=None, help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists." ) + chat_parser.add_argument( + "--tui", + action="store_true", + default=False, + help="Launch the Ink-based terminal UI instead of the classic REPL" + ) chat_parser.set_defaults(func=cmd_chat) # ========================================================================= From afd670a36f5a776f1a596fbcde9d8158e1060880 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 6 Apr 2026 18:38:13 -0500 Subject: [PATCH 012/157] feat: small refactors --- hermes_cli/main.py | 37 +- package-lock.json | 2372 +++++++++++++++++++++- tests/test_tui_gateway_server.py | 79 + tui_gateway/entry.py | 22 +- tui_gateway/server.py | 24 +- ui-tui/src/app.tsx | 114 +- ui-tui/src/components/markdown.tsx | 10 +- ui-tui/src/components/queuedMessages.tsx | 35 +- ui-tui/src/components/thinking.tsx | 12 +- ui-tui/src/constants.ts | 1 + ui-tui/src/gatewayClient.ts | 80 +- ui-tui/src/lib/text.ts | 62 +- 12 files changed, 2780 insertions(+), 68 deletions(-) create mode 100644 tests/test_tui_gateway_server.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 5a9c8d7e97..c08f3f6462 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -45,6 +45,7 @@ Usage: import argparse import os +import shutil import subprocess import sys from pathlib import Path @@ -562,7 +563,16 @@ def _launch_tui(): print(f" cd {tui_dir} && npm install") sys.exit(1) - sys.exit(subprocess.call(["npm", "start"], cwd=str(tui_dir))) + tsx = tui_dir / "node_modules" / ".bin" / "tsx" + if tsx.exists(): + sys.exit(subprocess.call([str(tsx), "src/entry.tsx"], cwd=str(tui_dir))) + + npm = shutil.which("npm") + if not npm: + print("npm not found in PATH. Source your nvm/node setup or set PATH.") + sys.exit(1) + + sys.exit(subprocess.call([npm, "start"], cwd=str(tui_dir))) def cmd_chat(args): @@ -5529,27 +5539,20 @@ Examples: # Handle top-level --resume / --continue as shortcut to chat if (args.resume or args.continue_last) and args.command is None: args.command = "chat" - args.query = None - args.model = None - args.provider = None - args.toolsets = None - args.verbose = False - if not hasattr(args, "worktree"): - args.worktree = False + for attr, default in [("query", None), ("model", None), ("provider", None), + ("toolsets", None), ("verbose", False), ("worktree", False)]: + if not hasattr(args, attr): + setattr(args, attr, default) cmd_chat(args) return # Default to chat if no command specified if args.command is None: - args.query = None - args.model = None - args.provider = None - args.toolsets = None - args.verbose = False - args.resume = None - args.continue_last = None - if not hasattr(args, "worktree"): - args.worktree = False + for attr, default in [("query", None), ("model", None), ("provider", None), + ("toolsets", None), ("verbose", False), ("resume", None), + ("continue_last", None), ("worktree", False)]: + if not hasattr(args, attr): + setattr(args, attr, default) cmd_chat(args) return diff --git a/package-lock.json b/package-lock.json index 1e54db9aa5..476e6938c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@askjo/camoufox-browser": "^1.0.0", "agent-browser": "^0.13.0" }, "engines": { @@ -38,6 +39,26 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/@askjo/camoufox-browser": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@askjo/camoufox-browser/-/camoufox-browser-1.0.12.tgz", + "integrity": "sha512-MxRvjK6SkX6zJSNleoO32g9iwhJAcXpaAgj4pik7y2SrYXqcHllpG7FfLkKE7d5bnBt7pO82rdarVYu6xtW2RA==", + "deprecated": "Renamed to @askjo/camofox-browser", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "camoufox-js": "^0.8.5", + "dotenv": "^17.2.3", + "express": "^4.18.2", + "playwright": "^1.50.0", + "playwright-core": "^1.58.0", + "playwright-extra": "^4.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -105,12 +126,39 @@ "node": ">=18" } }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.19.33", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", @@ -263,6 +311,28 @@ "node": ">=6.5" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/adm-zip": { + "version": "0.5.17", + "resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz", + "integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -358,6 +428,21 @@ "node": ">= 0.4" } }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -522,6 +607,18 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-ftp": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz", @@ -531,12 +628,135 @@ "node": ">=10.0.0" } }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bl/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "license": "MIT" }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -552,6 +772,39 @@ "balanced-match": "^1.0.0" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -591,6 +844,188 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camoufox-js": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/camoufox-js/-/camoufox-js-0.8.5.tgz", + "integrity": "sha512-20ihPbspAcOVSUTX9Drxxp0C116DON1n8OVA1eUDglWZiHwiHwFVFOMrIEBwAHMZpU11mIEH/kawJtstRIrDPA==", + "license": "MPL-2.0", + "dependencies": { + "adm-zip": "^0.5.16", + "better-sqlite3": "^12.2.0", + "commander": "^14.0.0", + "fingerprint-generator": "^2.1.66", + "glob": "^13.0.0", + "impit": "^0.7.0", + "language-tags": "^2.0.1", + "maxmind": "^5.0.0", + "progress": "^2.0.3", + "ua-parser-js": "^2.0.2", + "xml2js": "^0.6.2" + }, + "bin": { + "camoufox-js": "dist/__main__.js" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "playwright-core": "*" + } + }, + "node_modules/camoufox-js/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/camoufox-js/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/camoufox-js/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/camoufox-js/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/camoufox-js/node_modules/lru-cache": { + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.2.tgz", + "integrity": "sha512-wgWa6FWQ3QRRJbIjbsldRJZxdxYngT/dO0I5Ynmlnin8qy7tC6xYzbcJjtN4wHLXtkbVwHzk0C+OejVw1XM+DQ==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/camoufox-js/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/camoufox-js/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001786", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001786.tgz", + "integrity": "sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -645,6 +1080,12 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -732,6 +1173,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/clone-deep": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-0.2.4.tgz", + "integrity": "sha512-we+NuQo2DHhSl+DP6jlUiAhyAjBQrYnpOk15rN6c6JSPScjiCLh8IbSU+VTcph6YS3o7mASE8a0+gbZ7ChLpgg==", + "license": "MIT", + "dependencies": { + "for-own": "^0.1.3", + "is-plain-object": "^2.0.1", + "kind-of": "^3.0.2", + "lazy-cache": "^1.0.3", + "shallow-clone": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -775,12 +1232,54 @@ "node": ">= 14" } }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", "license": "ISC" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -924,6 +1423,39 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deepmerge-ts": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", @@ -947,6 +1479,54 @@ "node": ">= 14" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -1002,6 +1582,47 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dot-prop": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1092,12 +1713,33 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "license": "ISC" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/encoding-sniffer": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", @@ -1132,6 +1774,36 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -1141,6 +1813,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -1193,6 +1871,15 @@ "node": ">=0.10.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -1220,6 +1907,76 @@ "bare-events": "^2.7.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -1296,6 +2053,80 @@ "pend": "~1.2.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fingerprint-generator": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/fingerprint-generator/-/fingerprint-generator-2.1.82.tgz", + "integrity": "sha512-5Z/yCKW324pMyMarpIKe/QPdkrFWKNJv3ktdU+fXHri80+HAwNE6QhMvEvsMkK9Q8DeCXZlpPHV77UBa1nFb4A==", + "license": "Apache-2.0", + "dependencies": { + "generative-bayesian-network": "^2.1.82", + "header-generator": "^2.1.82", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/for-own": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-0.1.5.tgz", + "integrity": "sha512-SKmowqGTJoPzLO1T0BBJpkfp3EMacCMOuH40hOUbrbzElVktk4DioXVM99QkLCyKoiuOmyjgcWMpVz2xjE7LZw==", + "license": "MIT", + "dependencies": { + "for-in": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -1312,6 +2143,73 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/geckodriver": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/geckodriver/-/geckodriver-6.1.0.tgz", @@ -1333,6 +2231,16 @@ "node": ">=20.0.0" } }, + "node_modules/generative-bayesian-network": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/generative-bayesian-network/-/generative-bayesian-network-2.1.82.tgz", + "integrity": "sha512-DH4NrmQheoMaJErdVv2IzaqkbOYSDQZmiZTV6UPDJYRDK2EyPpIQ88XRcYdPeFrUjS1N0Jj25H3HUywoJ1dbow==", + "license": "Apache-2.0", + "dependencies": { + "adm-zip": "^0.5.9", + "tslib": "^2.4.0" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -1342,6 +2250,30 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/get-port": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", @@ -1354,6 +2286,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -1383,6 +2328,12 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -1404,6 +2355,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -1425,6 +2388,45 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-generator": { + "version": "2.1.82", + "resolved": "https://registry.npmjs.org/header-generator/-/header-generator-2.1.82.tgz", + "integrity": "sha512-4NjPB0+bAKjPoponSmTOkK58IEF2W22sOJA5O48k/MxbCZgOm+jrU4WVR53Z2I6xFgIPkVrQmKtt1LAbWtfqXw==", + "license": "Apache-2.0", + "dependencies": { + "browserslist": "^4.21.1", + "generative-bayesian-network": "^2.1.82", + "ow": "^0.28.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/htmlfy": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/htmlfy/-/htmlfy-0.8.1.tgz", @@ -1462,6 +2464,26 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -1526,6 +2548,153 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/impit": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit/-/impit-0.7.6.tgz", + "integrity": "sha512-AkS6Gv63+E6GMvBrcRhMmOREKpq5oJ0J5m3xwfkHiEs97UIsbpEqFmW3sFw/sdyOTDGRF5q4EjaLxtb922Ta8g==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "impit-darwin-arm64": "0.7.6", + "impit-darwin-x64": "0.7.6", + "impit-linux-arm64-gnu": "0.7.6", + "impit-linux-arm64-musl": "0.7.6", + "impit-linux-x64-gnu": "0.7.6", + "impit-linux-x64-musl": "0.7.6", + "impit-win32-arm64-msvc": "0.7.6", + "impit-win32-x64-msvc": "0.7.6" + } + }, + "node_modules/impit-darwin-arm64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.7.6.tgz", + "integrity": "sha512-M7NQXkttyzqilWfzVkNCp7hApT69m0etyJkVpHze4bR5z1kJnHhdsb8BSdDv2dzvZL4u1JyqZNxq+qoMn84eUw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-darwin-x64": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.7.6.tgz", + "integrity": "sha512-kikTesWirAwJp9JPxzGLoGVc+heBlEabWS5AhTkQedACU153vmuL90OBQikVr3ul2N0LPImvnuB+51wV0zDE6g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.7.6.tgz", + "integrity": "sha512-H6GHjVr/0lG9VEJr6IHF8YLq+YkSIOF4k7Dfue2ygzUAj1+jZ5ZwnouhG/XrZHYW6EWsZmEAjjRfWE56Q0wDRQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-arm64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.7.6.tgz", + "integrity": "sha512-1sCB/UBVXLZTpGJsXRdNNSvhN9xmmQcYLMWAAB4Itb7w684RHX1pLoCb6ichv7bfAf6tgaupcFIFZNBp3ghmQA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-gnu": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.7.6.tgz", + "integrity": "sha512-yYhlRnZ4fhKt8kuGe0JK2WSHc8TkR6BEH0wn+guevmu8EOn9Xu43OuRvkeOyVAkRqvFnlZtMyySUo/GuSLz9Gw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-linux-x64-musl": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.7.6.tgz", + "integrity": "sha512-sdGWyu+PCLmaOXy7Mzo4WP61ZLl5qpZ1L+VeXW+Ycazgu0e7ox0NZLdiLRunIrEzD+h0S+e4CyzNwaiP3yIolg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-arm64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.7.6.tgz", + "integrity": "sha512-sM5deBqo0EuXg5GACBUMKEua9jIau/i34bwNlfrf/Amnw1n0GB4/RkuUh+sKiUcbNAntrRq+YhCq8qDP8IW19w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/impit-win32-x64-msvc": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.7.6.tgz", + "integrity": "sha512-ry63ADGLCB/PU/vNB1VioRt2V+klDJ34frJUXUZBEv1kA96HEAg9AxUk+604o+UHS3ttGH2rkLmrbwHOdAct5Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -1536,12 +2705,29 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -1551,6 +2737,30 @@ "node": ">= 12" } }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "license": "MIT" + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -1560,6 +2770,15 @@ "node": ">=8" } }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -1572,6 +2791,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -1599,6 +2850,15 @@ "node": ">=18" } }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -1623,6 +2883,18 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -1665,6 +2937,45 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-2.1.0.tgz", + "integrity": "sha512-D4CgpyCt+61f6z2jHjJS1OmZPviAWM57iJ9OKdFFWSNgS7Udj9QVWqyGs/cveVNF57XpZmhSvMdVIV5mjLA7Vg==", + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/lazystream": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", @@ -1749,6 +3060,13 @@ "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.zip": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", @@ -1780,6 +3098,115 @@ "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/maxmind": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.6.tgz", + "integrity": "sha512-5bvd/u+kIaTqaGM+xkXjatzQw1dQfSmlLggr2W1EKMyMxSgx2woZyusLpNpZ4DdPmL+1bbJWeo4LXsi6bC0Iew==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "3.0.2", + "tiny-lru": "13.0.0" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-deep": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/merge-deep/-/merge-deep-3.0.3.tgz", + "integrity": "sha512-qtmzAS6t6grwEkNrunqTBdn0qKwFgNWvlxUbAV8es9M7Ot1EbyApytCnvE0jALPa46ZpKDUo527kKiaWplmlFA==", + "license": "MIT", + "dependencies": { + "arr-union": "^3.1.0", + "clone-deep": "^0.2.4", + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -1795,6 +3222,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -1810,6 +3246,44 @@ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mixin-object": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mixin-object/-/mixin-object-2.0.1.tgz", + "integrity": "sha512-ALGF1Jt9ouehcaXaHhn6t1yGWRqGaHkPFndtFVHfZXOvkIZ/yoGaSi0AHVTafb3ZBGg4dr/bDwnaEKqCXzchMA==", + "license": "MIT", + "dependencies": { + "for-in": "^0.1.3", + "is-extendable": "^0.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mixin-object/node_modules/for-in": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-0.1.8.tgz", + "integrity": "sha512-F0to7vbBSHP8E3l6dCjxNOLuSFAACIxFy3UehTUlG7svlXi37HHsDkyVcHo0Pq8QwrE+pXvWSVX3ZT1T9wAZ9g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/mmdb-lib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz", + "integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "node_modules/modern-tar": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.4.tgz", @@ -1825,6 +3299,21 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/netmask": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", @@ -1834,6 +3323,24 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, "node_modules/node-simctl": { "version": "7.7.5", "resolved": "https://registry.npmjs.org/node-simctl/-/node-simctl-7.7.5.tgz", @@ -1877,6 +3384,30 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1886,6 +3417,25 @@ "wrappy": "1" } }, + "node_modules/ow": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/ow/-/ow-0.28.2.tgz", + "integrity": "sha512-dD4UpyBh/9m4X2NVjA+73/ZPBRF+uF4zIMFvvQsabMiEK8x41L3rQ8EENOi35kyyoaJwNxEeJcP6Fj1H4U409Q==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.2.0", + "callsites": "^3.1.0", + "dot-prop": "^6.0.1", + "lodash.isequal": "^4.5.0", + "vali-date": "^1.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/pac-proxy-agent": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz", @@ -1979,6 +3529,15 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-expression-matcher": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", @@ -1994,6 +3553,15 @@ "node": ">=14.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2019,16 +3587,46 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, "node_modules/playwright-core": { - "version": "1.58.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", - "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -2037,6 +3635,99 @@ "node": ">=18" } }, + "node_modules/playwright-extra": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/playwright-extra/-/playwright-extra-4.3.6.tgz", + "integrity": "sha512-q2rVtcE8V8K3vPVF1zny4pvwZveHLH8KBuVU2MoE3Jw4OKVoBWsHI9CH9zPydovHHOCDxjGN2Vg+2m644q3ijA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "playwright": "*", + "playwright-core": "*" + }, + "peerDependenciesMeta": { + "playwright": { + "optional": true + }, + "playwright-core": { + "optional": true + } + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -2061,6 +3752,19 @@ "node": ">=0.4.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -2105,12 +3809,243 @@ "once": "^1.3.1" } }, + "node_modules/puppeteer-extra-plugin": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin/-/puppeteer-extra-plugin-3.2.3.tgz", + "integrity": "sha512-6RNy0e6pH8vaS3akPIKGg28xcryKscczt4wIl0ePciZENGE2yoaQJNd17UiEbdmh5/6WW6dPcfRWT9lxBwCi2Q==", + "license": "MIT", + "dependencies": { + "@types/debug": "^4.1.0", + "debug": "^4.1.1", + "merge-deep": "^3.0.1" + }, + "engines": { + "node": ">=9.11.2" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-stealth": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-stealth/-/puppeteer-extra-plugin-stealth-2.11.2.tgz", + "integrity": "sha512-bUemM5XmTj9i2ZerBzsk2AN5is0wHMNE6K0hXBzBXOzP5m5G3Wl0RHhiqKeHToe/uIH8AoZiGhc1tCkLZQPKTQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-preferences": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-data-dir/-/puppeteer-extra-plugin-user-data-dir-2.4.1.tgz", + "integrity": "sha512-kH1GnCcqEDoBXO7epAse4TBPJh9tEpVEK/vkedKfjOVOhZAvLkHGc9swMs5ChrJbRnf8Hdpug6TJlEuimXNQ+g==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "fs-extra": "^10.0.0", + "puppeteer-extra-plugin": "^3.2.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/puppeteer-extra-plugin-user-data-dir/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/puppeteer-extra-plugin-user-preferences": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/puppeteer-extra-plugin-user-preferences/-/puppeteer-extra-plugin-user-preferences-2.4.1.tgz", + "integrity": "sha512-i1oAZxRbc1bk8MZufKCruCEC3CCafO9RKMkkodZltI4OqibLFXF3tj6HZ4LZ9C5vCXZjYcDWazgtY69mnmrQ9A==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "deepmerge": "^4.2.2", + "puppeteer-extra-plugin": "^3.2.3", + "puppeteer-extra-plugin-user-data-dir": "^2.4.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "playwright-extra": "*", + "puppeteer-extra": "*" + }, + "peerDependenciesMeta": { + "playwright-extra": { + "optional": true + }, + "puppeteer-extra": { + "optional": true + } + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/query-selector-shadow-dom": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, "node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -2250,6 +4185,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -2262,6 +4206,45 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/serialize-error": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-12.0.0.tgz", @@ -2289,6 +4272,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -2301,6 +4299,48 @@ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", "license": "MIT" }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-0.1.2.tgz", + "integrity": "sha512-J1zdXCky5GmNnuauESROVu31MQSnLoYvlyEn6j2Ztk6Q5EHFIhxkMhYcv6vuDzl2XEzoRr856QwzMgWM/TmZgw==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.1", + "kind-of": "^2.0.1", + "lazy-cache": "^0.2.3", + "mixin-object": "^2.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/kind-of": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-2.0.1.tgz", + "integrity": "sha512-0u8i1NZ/mg0b+W3MGGw5I7+6Eib2nx72S/QvXa0hYjEkjTknYmEYQJwGu3mLC0BrhtJjtQafTkyRUQ75Kx0LVg==", + "license": "MIT", + "dependencies": { + "is-buffer": "^1.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shallow-clone/node_modules/lazy-cache": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-0.2.7.tgz", + "integrity": "sha512-gkX52wvU/R8DVMMt78ATVPFMJqfW8FPz1GZ1sVHBVQHmu/WvhIWE4cE1GBzhJNFicDeYhnwp6Rl35BcAIM3YOQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2334,6 +4374,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -2346,6 +4458,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -2428,6 +4585,15 @@ "node": ">= 10.x" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/streamx": { "version": "2.23.0", "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz", @@ -2544,6 +4710,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strnum": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", @@ -2628,12 +4803,42 @@ "b4a": "^1.6.4" } }, + "node_modules/tiny-lru": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-13.0.0.tgz", + "integrity": "sha512-xDHxKKS1FdF0Tv2P+QT7IeSEg74K/8cEDzbv3Tv6UyHHUgBOjOiQiBp818MGj66dhurQus/IBcoAbwIKtSGc6Q==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-fest": { "version": "4.26.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.0.tgz", @@ -2646,6 +4851,70 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/undici": { "version": "7.24.6", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.6.tgz", @@ -2661,6 +4930,54 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/urlpattern-polyfill": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", @@ -2682,6 +4999,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", @@ -2695,6 +5021,24 @@ "uuid": "dist/esm/bin/uuid" } }, + "node_modules/vali-date": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/vali-date/-/vali-date-1.0.0.tgz", + "integrity": "sha512-sgECfZthyaCKW10N0fm27cg8HYTFK5qMWgypqkXMQ4Wbl/zZKx7xZICgcoxIIE+WFAP/MBL2EFwC/YvLxw3Zeg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/wait-port": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/wait-port/-/wait-port-1.1.0.tgz", @@ -2973,6 +5317,28 @@ } } }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py new file mode 100644 index 0000000000..b519621135 --- /dev/null +++ b/tests/test_tui_gateway_server.py @@ -0,0 +1,79 @@ +import json +import threading +import time +from unittest.mock import patch + +from tui_gateway import server + + +class _ChunkyStdout: + def __init__(self): + self.parts: list[str] = [] + + def write(self, text: str) -> int: + for ch in text: + self.parts.append(ch) + time.sleep(0.0001) + return len(text) + + def flush(self) -> None: + return None + + +class _BrokenStdout: + def write(self, text: str) -> int: + raise BrokenPipeError + + def flush(self) -> None: + return None + + +def test_write_json_serializes_concurrent_writes(monkeypatch): + out = _ChunkyStdout() + monkeypatch.setattr(server.sys, "stdout", out) + + threads = [ + threading.Thread(target=server.write_json, args=({"seq": i, "text": "x" * 24},)) + for i in range(8) + ] + + for t in threads: + t.start() + + for t in threads: + t.join() + + lines = "".join(out.parts).splitlines() + + assert len(lines) == 8 + assert {json.loads(line)["seq"] for line in lines} == set(range(8)) + + +def test_write_json_returns_false_on_broken_pipe(monkeypatch): + monkeypatch.setattr(server.sys, "stdout", _BrokenStdout()) + + assert server.write_json({"ok": True}) is False + + +def test_status_callback_emits_kind_and_text(): + with patch("tui_gateway.server._emit") as emit: + cb = server._agent_cbs("sid")["status_callback"] + cb("context_pressure", "85% to compaction") + + emit.assert_called_once_with( + "status.update", + "sid", + {"kind": "context_pressure", "text": "85% to compaction"}, + ) + + +def test_status_callback_accepts_single_message_argument(): + with patch("tui_gateway.server._emit") as emit: + cb = server._agent_cbs("sid")["status_callback"] + cb("thinking...") + + emit.assert_called_once_with( + "status.update", + "sid", + {"kind": "status", "text": "thinking..."}, + ) diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 697b75b596..9284ba28ef 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -2,25 +2,17 @@ import json import signal import sys -from tui_gateway.server import handle_request, resolve_skin +from tui_gateway.server import handle_request, resolve_skin, write_json signal.signal(signal.SIGPIPE, signal.SIG_DFL) - -def _write(obj: dict): - try: - sys.stdout.write(json.dumps(obj) + "\n") - sys.stdout.flush() - except BrokenPipeError: - sys.exit(0) - - def main(): - _write({ + if not write_json({ "jsonrpc": "2.0", "method": "event", "params": {"type": "gateway.ready", "payload": {"skin": resolve_skin()}}, - }) + }): + sys.exit(0) for raw in sys.stdin: line = raw.strip() @@ -30,12 +22,14 @@ def main(): try: req = json.loads(line) except json.JSONDecodeError: - _write({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}) + if not write_json({"jsonrpc": "2.0", "error": {"code": -32700, "message": "parse error"}, "id": None}): + sys.exit(0) continue resp = handle_request(req) if resp is not None: - _write(resp) + if not write_json(resp): + sys.exit(0) if __name__ == "__main__": diff --git a/tui_gateway/server.py b/tui_gateway/server.py index d788b12a38..7a589db2cf 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -19,6 +19,7 @@ _methods: dict[str, callable] = {} _pending: dict[str, threading.Event] = {} _answers: dict[str, str] = {} _db = None +_stdout_lock = threading.Lock() # ── Plumbing ────────────────────────────────────────────────────────── @@ -31,12 +32,29 @@ def _get_db(): return _db +def write_json(obj: dict) -> bool: + line = json.dumps(obj, ensure_ascii=False) + "\n" + try: + with _stdout_lock: + sys.stdout.write(line) + sys.stdout.flush() + return True + except BrokenPipeError: + return False + + def _emit(event: str, sid: str, payload: dict | None = None): params = {"type": event, "session_id": sid} if payload: params["payload"] = payload - sys.stdout.write(json.dumps({"jsonrpc": "2.0", "method": "event", "params": params}) + "\n") - sys.stdout.flush() + write_json({"jsonrpc": "2.0", "method": "event", "params": params}) + + +def _status_update(sid: str, kind: str, text: str | None = None): + body = (text if text is not None else kind).strip() + if not body: + return + _emit("status.update", sid, {"kind": kind if text is not None else "status", "text": body}) def _ok(rid, result: dict) -> dict: @@ -164,7 +182,7 @@ def _agent_cbs(sid: str) -> dict: 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}), + status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)), clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), ) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index f170ea8cf9..b52a305080 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,3 +1,8 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + import { Box, Text, useApp, useInput, useStdout } from 'ink' import TextInput from 'ink-text-input' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -8,7 +13,7 @@ import { CommandPalette } from './components/commandPalette.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' -import { QueuedMessages } from './components/queuedMessages.js' +import { estimateQueuedRows, QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { Thinking } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' @@ -73,6 +78,9 @@ export function App({ gw }: { gw: GatewayClient }) { const historyDraftRef = useRef('') const queueEditRef = useRef(null) const lastEmptyAt = useRef(0) + const lastStatusNoteRef = useRef('') + const protocolWarnedRef = useRef(false) + const stderrWarnedRef = useRef(false) const empty = !messages.length const blocked = !!(clarify || approval || sudo || secret || picker) @@ -130,7 +138,17 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps - const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - (thinking ? 2 : 0) - 2) + const paletteMatches = useMemo(() => (!blocked && input.startsWith('/') ? paletteForLine(input, catalog) : []), [ + blocked, + catalog, + input + ]) + + const queueRows = useMemo(() => estimateQueuedRows(queuedDisplay.length, queueEditIdx), [queueEditIdx, queuedDisplay.length]) + const thinkingRows = thinking ? Math.max(1, tools.length || 1) + (reasoning || thinkingText ? 1 : 0) : 0 + const paletteRows = paletteMatches.length ? paletteMatches.length + 1 : 0 + const footerRows = statusBar ? 1 : 0 + const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - thinkingRows - queueRows - paletteRows - footerRows - 2) const viewport = useMemo(() => { if (!messages.length) { @@ -146,7 +164,8 @@ export function App({ gw }: { gw: GatewayClient }) { for (let i = end - 1; i >= 0 && budget > 0; i--) { const msg = messages[i]! const margin = msg.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 - budget -= margin + estimateRows(msg.role === 'user' ? userDisplay(msg.text) : msg.text, width) + const text = msg.role === 'user' ? userDisplay(msg.text) : msg.text + budget -= margin + estimateRows(text, width, compact && msg.role === 'assistant') if (budget >= 0) { start = i @@ -162,7 +181,7 @@ export function App({ gw }: { gw: GatewayClient }) { } return { above: start, end, start } - }, [cols, messages, msgBudget, scrollOffset]) + }, [cols, compact, messages, msgBudget, scrollOffset]) const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), []) @@ -181,6 +200,9 @@ export function App({ gw }: { gw: GatewayClient }) { setMessages([]) setUsage(ZERO) setStatus('ready') + lastStatusNoteRef.current = '' + protocolWarnedRef.current = false + stderrWarnedRef.current = false if (msg) { sys(msg) @@ -273,6 +295,34 @@ export function App({ gw }: { gw: GatewayClient }) { sys(r.attached ? `📎 image #${r.count} attached` : r.message || 'no image in clipboard') ) + const openEditor = () => { + const editor = process.env.EDITOR || process.env.VISUAL || 'vi' + const dir = mkdtempSync(join(tmpdir(), 'hermes-')) + const file = join(dir, 'prompt.md') + + writeFileSync(file, [...inputBuf, input].join('\n')) + + process.stdout.write('\x1b[?1049l') + const { status } = spawnSync(editor, [file], { stdio: 'inherit' }) + process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') + + if (status === 0) { + try { + const text = readFileSync(file, 'utf8').trimEnd() + + if (text) { + setInput('') + setInputBuf([]) + submit(text) + } + } catch {} + } + + try { + unlinkSync(file) + } catch {} + } + const interpolate = (text: string, then: (result: string) => void) => { setStatus('interpolating…') const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] @@ -409,6 +459,10 @@ export function App({ gw }: { gw: GatewayClient }) { setMessages([]) } + if (key.ctrl && ch === 'g') { + return openEditor() + } + if (key.ctrl && ch === 'v') { return paste() } @@ -471,6 +525,29 @@ export function App({ gw }: { gw: GatewayClient }) { case 'status.update': if (p?.text) { setStatus(p.text) + + if (p.kind && p.kind !== 'status' && lastStatusNoteRef.current !== p.text) { + lastStatusNoteRef.current = p.text + sys(p.text) + } + } + + break + + case 'gateway.stderr': + if (!stderrWarnedRef.current) { + stderrWarnedRef.current = true + sys('gateway stderr captured · /logs to inspect') + } + + break + + case 'gateway.protocol_error': + setStatus('protocol warning') + + if (!protocolWarnedRef.current) { + protocolWarnedRef.current = true + sys('protocol noise detected · /logs to inspect') } break @@ -785,6 +862,14 @@ export function App({ gw }: { gw: GatewayClient }) { ) return true + case 'logs': { + const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20)) + const out = gw.getLogTail(limit) + + sys(out || 'no gateway logs') + + return true + } case 'resume': setPicker(true) @@ -1436,6 +1521,15 @@ export function App({ gw }: { gw: GatewayClient }) { ? theme.color.warn : theme.color.dim + const footer = [ + sid ? `session ${sid}` : 'no session', + info?.model ? info.model.split('/').pop() : '', + queuedDisplay.length ? `queue ${queuedDisplay.length}` : '', + usage.total > 0 ? `${fmtK(usage.total)} tok` : '' + ] + .filter(Boolean) + .join(' · ') + return ( @@ -1573,6 +1667,9 @@ export function App({ gw }: { gw: GatewayClient }) { setSid(r.session_id) setMessages([]) setUsage(ZERO) + lastStatusNoteRef.current = '' + protocolWarnedRef.current = false + stderrWarnedRef.current = false sys(`resumed session (${r.message_count} messages)`) setStatus('ready') }) @@ -1585,10 +1682,17 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} - {!blocked && input.startsWith('/') && } + {!!paletteMatches.length && } + {statusBar && ( + + {status} + {footer ? ` · ${footer}` : ''} + + )} + {'─'.repeat(cols - 2)} {!blocked && ( diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 7b1d157755..6c8296cc9d 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -90,11 +90,17 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st } i++ + const isDiff = lang === 'diff' + nodes.push( - {lang && {'─ ' + lang}} + {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => ( - + {l} ))} diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index 07119fac36..7bfe7227ae 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -3,6 +3,27 @@ import { Box, Text } from 'ink' import { compactPreview } from '../lib/text.js' import type { Theme } from '../theme.js' +export const QUEUE_WINDOW = 3 + +export function getQueueWindow(queueLen: number, queueEditIdx: number | null) { + const start = + queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, Math.max(0, queueLen - QUEUE_WINDOW))) + + const end = Math.min(queueLen, start + QUEUE_WINDOW) + + return { end, showLead: start > 0, showTail: end < queueLen, start } +} + +export function estimateQueuedRows(queueLen: number, queueEditIdx: number | null): number { + if (!queueLen) { + return 0 + } + + const win = getQueueWindow(queueLen, queueEditIdx) + + return 1 + (win.showLead ? 1 : 0) + (win.end - win.start) + (win.showTail ? 1 : 0) +} + export function QueuedMessages({ cols, queueEditIdx, @@ -18,23 +39,21 @@ export function QueuedMessages({ return null } - const qWindow = 3 - const qStart = queueEditIdx === null ? 0 : Math.max(0, Math.min(queueEditIdx - 1, queued.length - qWindow)) - const qEnd = Math.min(queued.length, qStart + qWindow) + const q = getQueueWindow(queued.length, queueEditIdx) return ( queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} - {qStart > 0 && ( + {q.showLead && ( {' '} … )} - {queued.slice(qStart, qEnd).map((item, i) => { - const idx = qStart + i + {queued.slice(q.start, q.end).map((item, i) => { + const idx = q.start + i const active = queueEditIdx === idx return ( @@ -43,9 +62,9 @@ export function QueuedMessages({ ) })} - {qEnd < queued.length && ( + {q.showTail && ( - {' '}…and {queued.length - qEnd} more + {' '}…and {queued.length - q.end} more )} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 8d1fbde210..e789ee7435 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -27,6 +27,8 @@ export function Thinking({ return () => clearInterval(id) }, []) + const tail = (reasoning || thinking || '').slice(-120).replace(/\n/g, ' ') + return ( {tools.length ? ( @@ -35,17 +37,15 @@ export function Thinking({ {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}… )) + ) : tail ? ( + + {SPINNER[frame]} 💭 {tail} + ) : ( {SPINNER[frame]} {face} {verb}… )} - {(reasoning || thinking) && ( - - {' 💭 '} - {(reasoning || thinking || '').slice(-120).replace(/\n/g, ' ')} - - )} ) } diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 87c1fdac27..2211a483eb 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -22,6 +22,7 @@ export const FACES = [ export const HOTKEYS: [string, string][] = [ ['Ctrl+C', 'interrupt / clear / exit'], ['Ctrl+D', 'exit'], + ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'clear screen'], ['Ctrl+V', 'paste clipboard image (same as /paste)'], ['Tab', 'complete /commands (registry-aware)'], diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index b104358bfe..3326109613 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -3,6 +3,9 @@ import { EventEmitter } from 'node:events' import { resolve } from 'node:path' import { createInterface } from 'node:readline' +const MAX_GATEWAY_LOG_LINES = 200 +const MAX_LOG_PREVIEW = 240 + export interface GatewayEvent { type: string session_id?: string @@ -17,6 +20,7 @@ interface Pending { export class GatewayClient extends EventEmitter { private proc: ChildProcess | null = null private reqId = 0 + private logs: string[] = [] private pending = new Map() start() { @@ -24,18 +28,40 @@ export class GatewayClient extends EventEmitter { this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], { cwd: root, - stdio: ['pipe', 'pipe', 'inherit'] + stdio: ['pipe', 'pipe', 'pipe'] }) createInterface({ input: this.proc.stdout! }).on('line', raw => { try { this.dispatch(JSON.parse(raw)) } catch { - /* malformed line */ + const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)' + this.pushLog(`[protocol] malformed stdout: ${preview}`) + this.emit('event', { type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent) } }) - this.proc.on('exit', code => this.emit('exit', code)) + createInterface({ input: this.proc.stderr! }).on('line', raw => { + const line = raw.trim() + + if (!line) { + return + } + + this.pushLog(line) + this.emit('event', { type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) + }) + + this.proc.on('error', err => { + this.pushLog(`[spawn] ${err.message}`) + this.rejectPending(new Error(`gateway error: ${err.message}`)) + this.emit('event', { type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent) + }) + + this.proc.on('exit', code => { + this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`)) + this.emit('exit', code) + }) } private dispatch(msg: Record) { @@ -54,19 +80,57 @@ export class GatewayClient extends EventEmitter { } } + private pushLog(line: string) { + this.logs.push(line) + + if (this.logs.length > MAX_GATEWAY_LOG_LINES) { + this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES) + } + } + + private rejectPending(err: Error) { + for (const [id, pending] of this.pending) { + this.pending.delete(id) + pending.reject(err) + } + } + + getLogTail(limit = 20): string { + return this.logs.slice(-Math.max(1, limit)).join('\n') + } + request(method: string, params: Record = {}): Promise { + if (!this.proc?.stdin) { + return Promise.reject(new Error('gateway not running')) + } + 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(() => { + const timeout = setTimeout(() => { if (this.pending.delete(id)) { reject(new Error(`timeout: ${method}`)) } }, 30_000) + + this.pending.set(id, { + reject: e => { + clearTimeout(timeout) + reject(e) + }, + resolve: v => { + clearTimeout(timeout) + resolve(v) + } + }) + + try { + this.proc!.stdin!.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n') + } catch (e) { + clearTimeout(timeout) + this.pending.delete(id) + reject(e instanceof Error ? e : new Error(String(e))) + } }) } diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index a441841c28..c24727484b 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -7,14 +7,72 @@ export const stripAnsi = (s: string) => s.replace(ANSI_RE, '') export const hasAnsi = (s: string) => s.includes('\x1b[') +const renderEstimateLine = (line: string) => { + const trimmed = line.trim() + + if (trimmed.startsWith('|')) { + return trimmed + .split('|') + .filter(Boolean) + .map(cell => cell.trim()) + .join(' ') + } + + return line + .replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/^#{1,3}\s+/, '') + .replace(/^\s*[-*]\s+/, '• ') + .replace(/^\s*(\d+)\.\s+/, '$1. ') + .replace(/^>\s?/, '│ ') +} + export const compactPreview = (s: string, max: number) => { const one = s.replace(/\s+/g, ' ').trim() return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } -export const estimateRows = (text: string, w: number) => - text.split('\n').reduce((sum, line) => sum + Math.ceil((stripAnsi(line).length || 1) / w), 0) +export const estimateRows = (text: string, w: number, compact = false) => { + let inCode = false + let rows = 0 + + for (const raw of text.split('\n')) { + const line = stripAnsi(raw) + + if (line.startsWith('```')) { + if (!inCode) { + const lang = line.slice(3).trim() + + if (lang) { + rows += Math.ceil((`─ ${lang}`.length || 1) / w) + } + } + + inCode = !inCode + + continue + } + + const trimmed = line.trim() + + if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) { + continue + } + + const rendered = inCode ? line : renderEstimateLine(line) + + if (compact && !rendered.trim()) { + continue + } + + rows += Math.ceil((rendered.length || 1) / w) + } + + return Math.max(1, rows) +} export const flat = (r: Record) => Object.values(r).flat() From 39878aff00bd147408f132642c14d5b990b6747b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 6 Apr 2026 18:40:21 -0500 Subject: [PATCH 013/157] chore: uptick --- ui-tui/src/app.tsx | 49 ++++++++++++-------- ui-tui/src/components/messageLine.tsx | 5 +- ui-tui/src/components/thinking.tsx | 67 ++++++++++++++++----------- 3 files changed, 72 insertions(+), 49 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index b52a305080..51369fc737 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -185,29 +185,38 @@ export function App({ gw }: { gw: GatewayClient }) { const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), []) - const rpc = (method: string, params: Record = {}) => - gw.request(method, params).catch((e: Error) => { - sys(`error: ${e.message}`) - }) + const colsRef = useRef(cols) + colsRef.current = cols - const newSession = (msg?: string) => - rpc('session.create', { cols }).then((r: any) => { - if (!r) { - return - } + const rpc = useCallback( + (method: string, params: Record = {}) => + gw.request(method, params).catch((e: Error) => { + sys(`error: ${e.message}`) + }), + [gw, sys] + ) - setSid(r.session_id) - setMessages([]) - setUsage(ZERO) - setStatus('ready') - lastStatusNoteRef.current = '' - protocolWarnedRef.current = false - stderrWarnedRef.current = false + const newSession = useCallback( + (msg?: string) => + rpc('session.create', { cols: colsRef.current }).then((r: any) => { + if (!r) { + return + } - if (msg) { - sys(msg) - } - }) + setSid(r.session_id) + setMessages([]) + setUsage(ZERO) + setStatus('ready') + lastStatusNoteRef.current = '' + protocolWarnedRef.current = false + stderrWarnedRef.current = false + + if (msg) { + sys(msg) + } + }), + [rpc, sys] + ) const idle = () => { setThinking(false) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index b2e8c914e6..5b9f4659a5 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,4 +1,5 @@ import { Box, Text } from 'ink' +import { memo } from 'react' import { LONG_MSG, ROLE } from '../constants.js' import { hasAnsi, userDisplay } from '../lib/text.js' @@ -7,7 +8,7 @@ import type { Msg } from '../types.js' import { Md } from './markdown.js' -export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; t: Theme }) { +export const MessageLine = memo(function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; t: Theme }) { const { body, glyph, prefix } = ROLE[msg.role](t) const content = (() => { @@ -47,4 +48,4 @@ export function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; {content} ) -} +}) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index e789ee7435..71e3ebf4f3 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,12 +1,26 @@ -import { Box, Text } from 'ink' -import { useEffect, useState } from 'react' +import { Text } from 'ink' +import { memo, useEffect, useRef, useState } from 'react' import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js' import { pick } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -export function Thinking({ +function SpinnerChar({ color }: { color: string }) { + const ref = useRef(0) + + useEffect(() => { + const id = setInterval(() => { + ref.current = (ref.current + 1) % SPINNER.length + }, 80) + + return () => clearInterval(id) + }, []) + + return {SPINNER[ref.current]} +} + +export const Thinking = memo(function Thinking({ reasoning, t, thinking, @@ -17,35 +31,34 @@ export function Thinking({ thinking?: string tools: ActiveTool[] }) { - 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) - }, []) - const tail = (reasoning || thinking || '').slice(-120).replace(/\n/g, ' ') - return ( - - {tools.length ? ( - tools.map(tool => ( + if (tools.length) { + return ( + <> + {tools.map(tool => ( - {SPINNER[frame]} {TOOL_VERBS[tool.name] ?? '⚡ ' + tool.name}… + ⚡ {TOOL_VERBS[tool.name] ?? tool.name}… - )) - ) : tail ? ( - - {SPINNER[frame]} 💭 {tail} - - ) : ( - - {SPINNER[frame]} {face} {verb}… - - )} - + ))} + + ) + } + + if (tail) { + return ( + + 💭 {tail} + + ) + } + + return ( + + {face} {verb}… + ) -} +}) From 2d349bbf7a06256089d8755e5c70be1ec23090dd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 6 Apr 2026 18:43:00 -0500 Subject: [PATCH 014/157] chore: fmt --- ui-tui/src/app.tsx | 59 +++++++++++++++--------------- ui-tui/src/components/markdown.tsx | 4 +- 2 files changed, 32 insertions(+), 31 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 51369fc737..ab0121cd64 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -138,13 +138,15 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps - const paletteMatches = useMemo(() => (!blocked && input.startsWith('/') ? paletteForLine(input, catalog) : []), [ - blocked, - catalog, - input - ]) + const paletteMatches = useMemo( + () => (!blocked && input.startsWith('/') ? paletteForLine(input, catalog) : []), + [blocked, catalog, input] + ) - const queueRows = useMemo(() => estimateQueuedRows(queuedDisplay.length, queueEditIdx), [queueEditIdx, queuedDisplay.length]) + const queueRows = useMemo( + () => estimateQueuedRows(queuedDisplay.length, queueEditIdx), + [queueEditIdx, queuedDisplay.length] + ) const thinkingRows = thinking ? Math.max(1, tools.length || 1) + (reasoning || thinkingText ? 1 : 0) : 0 const paletteRows = paletteMatches.length ? paletteMatches.length + 1 : 0 const footerRows = statusBar ? 1 : 0 @@ -306,30 +308,28 @@ export function App({ gw }: { gw: GatewayClient }) { const openEditor = () => { const editor = process.env.EDITOR || process.env.VISUAL || 'vi' - const dir = mkdtempSync(join(tmpdir(), 'hermes-')) - const file = join(dir, 'prompt.md') + const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') writeFileSync(file, [...inputBuf, input].join('\n')) - process.stdout.write('\x1b[?1049l') - const { status } = spawnSync(editor, [file], { stdio: 'inherit' }) + const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' }) process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') - if (status === 0) { - try { - const text = readFileSync(file, 'utf8').trimEnd() + if (code === 0) { + const text = readFileSync(file, 'utf8').trimEnd() - if (text) { - setInput('') - setInputBuf([]) - submit(text) - } - } catch {} + if (text) { + setInput('') + setInputBuf([]) + submit(text) + } } try { unlinkSync(file) - } catch {} + } catch (_) { + /* cleanup best-effort */ + } } const interpolate = (text: string, then: (result: string) => void) => { @@ -1530,15 +1530,6 @@ export function App({ gw }: { gw: GatewayClient }) { ? theme.color.warn : theme.color.dim - const footer = [ - sid ? `session ${sid}` : 'no session', - info?.model ? info.model.split('/').pop() : '', - queuedDisplay.length ? `queue ${queuedDisplay.length}` : '', - usage.total > 0 ? `${fmtK(usage.total)} tok` : '' - ] - .filter(Boolean) - .join(' · ') - return ( @@ -1698,7 +1689,15 @@ export function App({ gw }: { gw: GatewayClient }) { {statusBar && ( {status} - {footer ? ` · ${footer}` : ''} + {' · '} + {[ + sid && `session ${sid}`, + info?.model?.split('/').pop(), + queuedDisplay.length && `queue ${queuedDisplay.length}`, + usage.total > 0 && `${fmtK(usage.total)} tok` + ] + .filter(Boolean) + .join(' · ')} )} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 6c8296cc9d..d7b3df17f2 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -97,7 +97,9 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => ( From 86308b6de49f64b2468904a422ea34edcb10bf41 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 6 Apr 2026 18:49:40 -0500 Subject: [PATCH 015/157] chore: better command support --- tui_gateway/server.py | 46 +++++++++++++++++++++++++++++++++++++++++++ ui-tui/src/app.tsx | 30 +++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7a589db2cf..a6e3907c33 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -397,6 +397,40 @@ def _(rid, params: dict) -> dict: return _err(rid, 5011, str(e)) +@method("session.branch") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + db = _get_db() + old_key = session["session_key"] + history = session.get("history", []) + if not history: + return _err(rid, 4008, "nothing to branch — send a message first") + new_key = f"tui-{uuid.uuid4().hex[:8]}" + branch_name = params.get("name", "") + try: + if branch_name: + title = branch_name + else: + current = db.get_session_title(old_key) or "branch" + title = db.get_next_title_in_lineage(current) if hasattr(db, "get_next_title_in_lineage") else f"{current} (branch)" + db.create_session(new_key, source="tui", model=_resolve_model(), parent_session_id=old_key) + for msg in history: + db.append_message(session_id=new_key, role=msg.get("role", "user"), content=msg.get("content")) + db.set_session_title(new_key, title) + except Exception as e: + return _err(rid, 5008, f"branch failed: {e}") + new_sid = uuid.uuid4().hex[:8] + os.environ["HERMES_SESSION_KEY"] = new_key + try: + agent = _make_agent(new_sid, new_key, session_id=new_key) + _init_session(new_sid, new_key, agent, list(history), cols=session.get("cols", 80)) + except Exception as e: + return _err(rid, 5000, f"agent init failed on branch: {e}") + return _ok(rid, {"session_id": new_sid, "title": title, "parent": old_key}) + + @method("session.interrupt") def _(rid, params: dict) -> dict: session, err = _sess(params, rid) @@ -776,9 +810,21 @@ def _(rid, params: dict) -> dict: return _err(rid, 5012, str(e)) +def _resolve_name(name: str) -> str: + try: + from hermes_cli.commands import resolve_command + r = resolve_command(name) + return r.name if r else name + except Exception: + return name + + @method("command.dispatch") def _(rid, params: dict) -> dict: name, arg = params.get("name", "").lstrip("/"), params.get("arg", "") + resolved = _resolve_name(name) + if resolved != name: + name = resolved session = _sessions.get(params.get("session_id", "")) qcmds = _load_cfg().get("quick_commands", {}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index ab0121cd64..ab41da1227 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -147,6 +147,7 @@ export function App({ gw }: { gw: GatewayClient }) { () => estimateQueuedRows(queuedDisplay.length, queueEditIdx), [queueEditIdx, queuedDisplay.length] ) + const thinkingRows = thinking ? Math.max(1, tools.length || 1) + (reasoning || thinkingText ? 1 : 0) : 0 const paletteRows = paletteMatches.length ? paletteMatches.length + 1 : 0 const footerRows = statusBar ? 1 : 0 @@ -750,6 +751,22 @@ export function App({ gw }: { gw: GatewayClient }) { return true + case 'branch': // falls through + + case 'fork': + if (!sid) { + return true + } + + rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { + setSid(r.session_id) + setUsage(ZERO) + sys(`branched → ${r.title}`) + setStatus('ready') + }) + + return true + case 'undo': if (!sid) { return true @@ -1109,7 +1126,9 @@ export function App({ gw }: { gw: GatewayClient }) { return true - case 'queue': + case 'queue': // falls through + + case 'q': if (!arg) { sys(`${queueRef.current.length} queued message(s)`) @@ -1409,11 +1428,12 @@ export function App({ gw }: { gw: GatewayClient }) { .catch(() => { gw.request('command.resolve', { name: name ?? '' }) .then((r: any) => { - if (r.canonical && r.canonical !== name) { - sys(`/${name} → /${r.canonical}`) - slash(`/${r.canonical}${arg ? ' ' + arg : ''}`) - } else { + if (!r.canonical || r.canonical === name) { sys(`unknown command: /${name}`) + } else if (r.category === 'cli-only') { + sys(`/${name} is CLI-only — run it in a terminal`) + } else { + send(`/${r.canonical}${arg ? ' ' + arg : ''}`) } }) .catch(() => sys(`unknown command: /${name}`)) From dcb97f7465f0572edabec79c449250475f99d17b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 6 Apr 2026 18:52:45 -0500 Subject: [PATCH 016/157] chore: readme --- ui-tui/README.md | 159 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 ui-tui/README.md diff --git a/ui-tui/README.md b/ui-tui/README.md new file mode 100644 index 0000000000..4b1e109c03 --- /dev/null +++ b/ui-tui/README.md @@ -0,0 +1,159 @@ +# Hermes TUI + +Ink-based terminal UI for the Hermes agent. Two processes, one stdio pipe: TypeScript renders the screen, Python runs the brain. + +``` +hermes --tui +``` + +## How it works + +The TUI spawns `tui_gateway.entry` as a child process. They talk newline-delimited JSON-RPC over stdin/stdout. Python handles sessions, tools, and LLM calls. Ink handles layout, input, and rendering. Stderr is piped into a ring buffer (never hits the terminal). + +``` +Ink (ui-tui/) Python (tui_gateway/) +───────────── ───────────────────── + TextInput entry.py + │ │ + ├─ JSON-RPC request ──────────► handle_request() + │ │ + │ server.py + │ 40 RPC methods + │ agent threads + │ │ + ◄── JSON-RPC response ─────────┤ + ◄── event push (streaming) ────┘ +``` + +All Python writes go through a locked `write_json()` so concurrent agent threads can't interleave bytes on stdout. + +## Layout + +The app runs in the alternate screen buffer. Everything fits in one fixed frame -- no native scroll. The message area gets whatever rows are left after subtracting chrome: + +``` +rows - header - thinking - queue - palette - statusbar - separator - input +``` + +The viewport walks backward from the newest message, estimating each one's rendered height, until the row budget runs out. PgUp/PgDn shift the window. + +## Hotkeys + +| Key | What it does | +|-----|-------------| +| Ctrl+C | Interrupt / clear / exit (contextual) | +| Ctrl+D | Exit | +| Ctrl+G | Open `$EDITOR` for multiline prompt | +| Ctrl+L | Clear messages | +| Ctrl+V | Paste clipboard image | +| Tab | Complete `/commands` | +| Up/Down | Cycle queue or input history | +| PgUp/PgDn | Scroll | +| Esc | Clear input | +| `\` + Enter | Continue on next line | +| `!cmd` | Shell command | +| `{!cmd}` | Interpolate shell output inline | + +## Ctrl+G editor + +Writes the current input to a temp file, leaves the alt screen, opens your `$EDITOR`, then reads the file back and submits it on save. Multiline `\`-continued input pre-populates the file. + +## Message queue + +Input typed while the agent is busy gets queued. The queue drains automatically after each response. Double-Enter sends the next queued item. Arrow keys let you edit queued messages before they send. + +## Rendering + +Assistant text goes through `markdown.tsx` -- a zero-dependency JSX renderer that handles code blocks (with diff coloring), headings, lists, quotes, tables, and inline formatting. If the Python side provides pre-rendered ANSI (via `agent.rich_output`), that takes priority. + +## Slash commands + +60+ commands wired in the local `slash()` switch. Anything unrecognized falls through to `command.dispatch` on the Python side (quick commands, plugins, skill commands) with alias resolution. `/help` lists them all. + +## Events (Python -> Ink) + +| Event | Payload | +|-------|---------| +| `gateway.ready` | `{ skin? }` | +| `session.info` | `{ model, tools, skills }` | +| `message.start` | -- | +| `message.delta` | `{ text, rendered? }` | +| `message.complete` | `{ text, rendered?, usage, status }` | +| `thinking.delta` | `{ text }` | +| `reasoning.delta` | `{ text }` | +| `status.update` | `{ kind, text }` | +| `tool.generating` | `{ name }` | +| `tool.start` | `{ tool_id, name }` | +| `tool.progress` | `{ name, preview }` | +| `tool.complete` | `{ tool_id, name }` | +| `clarify.request` | `{ question, choices?, request_id }` | +| `approval.request` | `{ command, description }` | +| `sudo.request` | `{ request_id }` | +| `secret.request` | `{ prompt, env_var, request_id }` | +| `background.complete` | `{ task_id, text }` | +| `btw.complete` | `{ text }` | +| `error` | `{ message }` | + +The client also synthesizes `gateway.stderr` and `gateway.protocol_error` from the child process. + +## RPC methods (40) + +Session: `session.create`, `session.list`, `session.resume`, `session.branch`, `session.title`, `session.usage`, `session.history`, `session.undo`, `session.compress`, `session.save`, `session.interrupt`, `terminal.resize` + +Prompts: `prompt.submit`, `prompt.background`, `prompt.btw` + +Responses: `clarify.respond`, `approval.respond`, `sudo.respond`, `secret.respond`, `clipboard.paste` + +Config: `config.set`, `config.get` + +System: `process.stop`, `reload.mcp`, `shell.exec`, `cli.exec`, `commands.catalog`, `command.resolve`, `command.dispatch` + +Features: `voice.toggle`, `voice.record`, `voice.tts`, `insights.get`, `rollback.list`, `rollback.restore`, `rollback.diff`, `browser.manage`, `plugins.list`, `cron.manage`, `skills.manage` + +## Performance notes + +- `MessageLine` and `Thinking` are `React.memo`'d so they skip re-render when the user types +- The spinner uses `useRef` instead of `useState` -- no parent re-renders at 80ms intervals +- `rpc` and `newSession` are `useCallback`-stable so the gateway event listener doesn't re-subscribe every render +- On a normal keystroke, only the input line re-renders. Viewport recalc only triggers on message/scroll changes. + +## Themes + +Python loads a skin from `~/.hermes/config.yaml` at startup. The `gateway.ready` event carries colors and branding to the client, which merges them into the default palette (gold/amber/bronze/cornsilk). Branding overrides the agent name, prompt symbol, and welcome text. + +## Files + +``` +ui-tui/src/ + entry.tsx entrypoint + app.tsx state, events, input, commands, layout + altScreen.tsx alternate screen buffer + gatewayClient.ts JSON-RPC child process bridge + constants.ts hotkeys, tool verbs, spinner frames + theme.ts palette + skin mapping + types.ts shared interfaces + banner.ts ASCII art + + lib/ + text.ts ANSI strip, row estimation + messages.ts streaming message upsert + history.ts persistent input history + slash.ts command palette + tab completion + osc52.ts clipboard via OSC 52 + + components/ + messageLine.tsx chat message (memoized) + markdown.tsx MD renderer (code, diff, tables) + thinking.tsx spinner / reasoning / tool progress + queuedMessages.tsx queue display + prompts.tsx approval + clarify + maskedPrompt.tsx sudo / secret input + sessionPicker.tsx session resume picker + commandPalette.tsx slash suggestions + branding.tsx welcome banner + session panel + +tui_gateway/ + entry.py stdin loop, JSON-RPC dispatch + server.py 40 RPC methods, session management + render.py optional Rich ANSI rendering bridge +``` From 29f2610e4b547d21fd951eb7c6f1b2cd3e58b23d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 7 Apr 2026 20:10:33 -0500 Subject: [PATCH 017/157] tui updates for rendering pipeline --- tui_gateway/server.py | 294 +++++- tui_gateway/slash_worker.py | 69 ++ ui-tui/src/app.tsx | 1162 +++++----------------- ui-tui/src/components/branding.tsx | 46 +- ui-tui/src/components/commandPalette.tsx | 3 +- ui-tui/src/components/markdown.tsx | 30 + ui-tui/src/components/messageLine.tsx | 47 +- ui-tui/src/components/textInput.tsx | 128 +++ ui-tui/src/components/thinking.tsx | 71 +- ui-tui/src/constants.ts | 6 +- ui-tui/src/lib/history.ts | 61 +- ui-tui/src/types.ts | 9 + 12 files changed, 896 insertions(+), 1030 deletions(-) create mode 100644 tui_gateway/slash_worker.py create mode 100644 ui-tui/src/components/textInput.tsx diff --git a/tui_gateway/server.py b/tui_gateway/server.py index a6e3907c33..0b4be63ded 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1,3 +1,4 @@ +import atexit import json import os import subprocess @@ -12,6 +13,12 @@ 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") +try: + from hermes_cli.banner import prefetch_update_check + prefetch_update_check() +except Exception: + pass + from tui_gateway.render import make_stream_renderer, render_diff, render_message _sessions: dict[str, dict] = {} @@ -21,6 +28,74 @@ _answers: dict[str, str] = {} _db = None _stdout_lock = threading.Lock() +# Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr +# so stray print() from libraries/tools becomes harmless gateway.stderr instead +# of corrupting the JSON protocol. +_real_stdout = sys.stdout +sys.stdout = sys.stderr + + +class _SlashWorker: + """Persistent HermesCLI subprocess for slash commands.""" + + def __init__(self, session_key: str, model: str): + self._lock = threading.Lock() + self._seq = 0 + self.stderr_tail: list[str] = [] + + argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key] + if model: + argv += ["--model", model] + + self.proc = subprocess.Popen( + argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, bufsize=1, cwd=os.getcwd(), env=os.environ.copy(), + ) + threading.Thread(target=self._drain_stderr, daemon=True).start() + + def _drain_stderr(self): + for line in (self.proc.stderr or []): + if text := line.rstrip("\n"): + self.stderr_tail = (self.stderr_tail + [text])[-80:] + + def run(self, command: str) -> str: + if self.proc.poll() is not None: + raise RuntimeError("slash worker exited") + + with self._lock: + self._seq += 1 + rid = self._seq + self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "\n") + self.proc.stdin.flush() + + for line in self.proc.stdout: + try: + msg = json.loads(line) + except json.JSONDecodeError: + continue + if msg.get("id") != rid: + continue + if not msg.get("ok"): + raise RuntimeError(msg.get("error", "slash worker failed")) + return str(msg.get("output", "")).rstrip() + + raise RuntimeError(f"slash worker closed pipe{': ' + chr(10).join(self.stderr_tail[-8:]) if self.stderr_tail else ''}") + + def close(self): + try: + if self.proc.poll() is None: + self.proc.terminate() + self.proc.wait(timeout=1) + except Exception: + try: self.proc.kill() + except Exception: pass + + +atexit.register(lambda: [ + s.get("slash_worker") and s["slash_worker"].close() + for s in _sessions.values() +]) + # ── Plumbing ────────────────────────────────────────────────────────── @@ -36,8 +111,8 @@ def write_json(obj: dict) -> bool: line = json.dumps(obj, ensure_ascii=False) + "\n" try: with _stdout_lock: - sys.stdout.write(line) - sys.stdout.flush() + _real_stdout.write(line) + _real_stdout.flush() return True except BrokenPipeError: return False @@ -158,7 +233,22 @@ def _get_usage(agent) -> dict: def _session_info(agent) -> dict: - info: dict = {"model": getattr(agent, "model", ""), "tools": {}, "skills": {}} + info: dict = { + "model": getattr(agent, "model", ""), + "tools": {}, + "skills": {}, + "cwd": os.getcwd(), + "version": "", + "release_date": "", + "update_behind": None, + "update_command": "", + } + try: + from hermes_cli import __version__, __release_date__ + info["version"] = __version__ + info["release_date"] = __release_date__ + except Exception: + pass try: from model_tools import get_toolset_for_tool for t in getattr(agent, "tools", []) or []: @@ -171,12 +261,27 @@ def _session_info(agent) -> dict: info["skills"] = get_available_skills() except Exception: pass + try: + from hermes_cli.banner import get_update_result + from hermes_cli.config import recommended_update_command + info["update_behind"] = get_update_result(timeout=0.5) + info["update_command"] = recommended_update_command() + except Exception: + pass return info +def _tool_ctx(name: str, args: dict) -> str: + try: + from agent.display import build_tool_preview + return build_tool_preview(name, args, max_len=80) or "" + except Exception: + return "" + + def _agent_cbs(sid: str) -> dict: return dict( - tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name}), + tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name, "context": _tool_ctx(name, args)}), 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}), @@ -222,7 +327,13 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "attached_images": [], "image_counter": 0, "cols": cols, + "slash_worker": None, } + try: + _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) + except Exception: + # Defer hard-failure to slash.exec; chat still works without slash worker. + _sessions[sid]["slash_worker"] = None try: from tools.approval import register_gateway_notify, load_permanent_allowlist register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) @@ -283,7 +394,7 @@ def _(rid, params: dict) -> dict: _init_session(sid, key, agent, [], cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"agent init failed: {e}") - return _ok(rid, {"session_id": sid}) + return _ok(rid, {"session_id": sid, "info": _session_info(agent)}) @method("session.list") @@ -324,7 +435,7 @@ def _(rid, params: dict) -> dict: _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") - return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history)}) + return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history), "info": _session_info(agent)}) @method("session.title") @@ -858,6 +969,177 @@ def _(rid, params: dict) -> dict: return _err(rid, 4018, f"not a quick/plugin/skill command: {name}") +# ── Methods: paste ──────────────────────────────────────────────────── + +_paste_counter = 0 + +@method("paste.collapse") +def _(rid, params: dict) -> dict: + global _paste_counter + text = params.get("text", "") + if not text: + return _err(rid, 4004, "empty paste") + + _paste_counter += 1 + line_count = text.count('\n') + 1 + paste_dir = _hermes_home / "pastes" + paste_dir.mkdir(parents=True, exist_ok=True) + + from datetime import datetime + paste_file = paste_dir / f"paste_{_paste_counter}_{datetime.now().strftime('%H%M%S')}.txt" + paste_file.write_text(text, encoding="utf-8") + + placeholder = f"[Pasted text #{_paste_counter}: {line_count} lines \u2192 {paste_file}]" + return _ok(rid, {"placeholder": placeholder, "path": str(paste_file), "lines": line_count}) + + +# ── Methods: complete ───────────────────────────────────────────────── + +@method("complete.path") +def _(rid, params: dict) -> dict: + word = params.get("word", "") + if not word: + return _ok(rid, {"items": []}) + + items: list[dict] = [] + try: + is_context = word.startswith("@") + query = word[1:] if is_context else word + + if is_context and not query: + items = [ + {"text": "@diff", "display": "@diff", "meta": "git diff"}, + {"text": "@staged", "display": "@staged", "meta": "staged diff"}, + {"text": "@file:", "display": "@file:", "meta": "attach file"}, + {"text": "@folder:", "display": "@folder:", "meta": "attach folder"}, + {"text": "@url:", "display": "@url:", "meta": "fetch url"}, + {"text": "@git:", "display": "@git:", "meta": "git log"}, + ] + return _ok(rid, {"items": items}) + + if is_context and query.startswith(("file:", "folder:")): + prefix_tag = query.split(":", 1)[0] + path_part = query.split(":", 1)[1] or "." + else: + prefix_tag = "" + path_part = query if not is_context else query + + expanded = os.path.expanduser(path_part) + if expanded.endswith("/"): + search_dir, match = expanded, "" + else: + search_dir = os.path.dirname(expanded) or "." + match = os.path.basename(expanded) + + match_lower = match.lower() + for entry in sorted(os.listdir(search_dir))[:200]: + if match and not entry.lower().startswith(match_lower): + continue + if is_context and not prefix_tag and entry.startswith("."): + continue + full = os.path.join(search_dir, entry) + is_dir = os.path.isdir(full) + rel = os.path.relpath(full) + suffix = "/" if is_dir else "" + + if is_context and prefix_tag: + text = f"@{prefix_tag}:{rel}{suffix}" + elif is_context: + kind = "folder" if is_dir else "file" + text = f"@{kind}:{rel}{suffix}" + elif word.startswith("~"): + text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix + else: + text = rel + suffix + + items.append({"text": text, "display": entry + suffix, "meta": "dir" if is_dir else ""}) + if len(items) >= 30: + break + except Exception: + pass + + return _ok(rid, {"items": items}) + + +@method("complete.slash") +def _(rid, params: dict) -> dict: + text = params.get("text", "") + if not text.startswith("/"): + return _ok(rid, {"items": []}) + + try: + from hermes_cli.commands import SlashCommandCompleter + from prompt_toolkit.document import Document + from prompt_toolkit.formatted_text import to_plain_text + + completer = SlashCommandCompleter() + doc = Document(text, len(text)) + items = [ + {"text": c.text, "display": c.display or c.text, + "meta": to_plain_text(c.display_meta) if c.display_meta else ""} + for c in completer.get_completions(doc, None) + ][:30] + return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}) + except Exception: + return _ok(rid, {"items": []}) + + +# ── Methods: slash.exec ────────────────────────────────────────────── + + +def _mirror_slash_side_effects(session: dict, command: str): + """Apply side effects that must also hit the gateway's live agent.""" + parts = command.lstrip("/").split(None, 1) + if not parts: + return + name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent") + + try: + if name == "model" and arg and agent: + from hermes_cli.model_switch import switch_model + switch_model(agent, arg) + elif name == "compress" and agent: + (getattr(agent, "compress_context", None) or getattr(agent, "context_compressor", agent).compress)() + elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): + agent.reload_mcp_tools() + elif name == "stop": + from tools.process_registry import ProcessRegistry + ProcessRegistry().kill_all() + except Exception: + pass + + +@method("slash.exec") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + + cmd = params.get("command", "").strip() + if not cmd: + return _err(rid, 4004, "empty command") + + worker = session.get("slash_worker") + if not worker: + try: + worker = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model())) + session["slash_worker"] = worker + except Exception as e: + return _err(rid, 5030, f"slash worker start failed: {e}") + + try: + output = worker.run(cmd) + _mirror_slash_side_effects(session, cmd) + return _ok(rid, {"output": output or "(no output)"}) + except Exception as e: + try: + worker.close() + except Exception: + pass + session["slash_worker"] = None + return _err(rid, 5030, str(e)) + + # ── Methods: voice ─────────────────────────────────────────────────── @method("voice.toggle") diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py new file mode 100644 index 0000000000..5d37418643 --- /dev/null +++ b/tui_gateway/slash_worker.py @@ -0,0 +1,69 @@ +"""Persistent slash-command worker — one HermesCLI per TUI session. + +Protocol: reads JSON lines from stdin {id, command}, writes {id, ok, output|error} to stdout. +""" + +import argparse +import contextlib +import io +import json +import os +import sys + +import cli as cli_mod +from cli import HermesCLI + + +def _run(cli: HermesCLI, command: str) -> str: + cmd = (command or "").strip() + if not cmd: + return "" + if not cmd.startswith("/"): + cmd = f"/{cmd}" + + buf = io.StringIO() + old = getattr(cli_mod, "_cprint", None) + if old is not None: + cli_mod._cprint = lambda text: print(text) + + try: + with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf): + cli.process_command(cmd) + finally: + if old is not None: + cli_mod._cprint = old + + return buf.getvalue().rstrip() + + +def main(): + p = argparse.ArgumentParser(add_help=False) + p.add_argument("--session-key", required=True) + p.add_argument("--model", default="") + args = p.parse_args() + + os.environ["HERMES_SESSION_KEY"] = args.session_key + os.environ["HERMES_INTERACTIVE"] = "1" + + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + cli = HermesCLI(model=args.model or None, compact=True, resume=args.session_key, verbose=False) + + for raw in sys.stdin: + line = raw.strip() + if not line: + continue + + rid = None + try: + req = json.loads(line) + rid = req.get("id") + out = _run(cli, req.get("command", "")) + sys.stdout.write(json.dumps({"id": rid, "ok": True, "output": out}) + "\n") + sys.stdout.flush() + except Exception as e: + sys.stdout.write(json.dumps({"id": rid, "ok": False, "error": str(e)}) + "\n") + sys.stdout.flush() + + +if __name__ == "__main__": + main() diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index ab41da1227..02b3f69210 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,28 +1,23 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' -import { Box, Text, useApp, useInput, useStdout } from 'ink' -import TextInput from 'ink-text-input' +import { Box, Static, Text, useApp, useInput, useStdout } from 'ink' +import { TextInput } from './components/textInput.js' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { AltScreen } from './altScreen.js' import { Banner, SessionPanel } from './components/branding.js' -import { CommandPalette } from './components/commandPalette.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' -import { estimateQueuedRows, QueuedMessages } from './components/queuedMessages.js' +import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { Thinking } from './components/thinking.js' -import { HOTKEYS, INTERPOLATION_RE, MAX_CTX, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import * as inputHistory from './lib/history.js' -import { upsert } from './lib/messages.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { paletteForLine, tabAdvance } from './lib/slash.js' -import { estimateRows, flat, fmtK, hasInterpolation, pick, userDisplay } from './lib/text.js' +import { fmtK, hasInterpolation, pick } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -37,16 +32,49 @@ import type { } from './types.js' const PLACEHOLDER = pick(PLACEHOLDERS) +const PASTE_REF_RE = /\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]/g + +const introMsg = (info: SessionInfo): Msg => ({ + role: 'system', + text: '', + kind: 'intro', + info +}) + +function extractTabWord(input: string): string | null { + const m = input.match(/((?:\.\.?\/|~\/|\/|@)[^\s]*)$/) + return m?.[1] ?? null +} + +function StatusRule({ cols, color, dimColor, statusColor, parts }: { + cols: number; color: string; dimColor: string; statusColor: string; parts: (string | false | undefined | null)[] +}) { + const label = parts.filter(Boolean).join(' · ') + const fill = Math.max(0, cols - label.length - 5) + + return ( + + {'─ '}{parts[0]}{label.slice(String(parts[0] || '').length)}{' ' + '─'.repeat(fill)} + + ) +} export function App({ gw }: { gw: GatewayClient }) { const { exit } = useApp() const { stdout } = useStdout() - const cols = stdout?.columns ?? 80 - const rows = stdout?.rows ?? 24 + const [cols, setCols] = useState(stdout?.columns ?? 80) + + useEffect(() => { + if (!stdout) return + const sync = () => setCols(stdout.columns ?? 80) + stdout.on('resize', sync) + return () => { stdout.off('resize', sync) } + }, [stdout]) const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) const [messages, setMessages] = useState([]) + const [historyItems, setHistoryItems] = useState([]) const [status, setStatus] = useState('summoning hermes…') const [sid, setSid] = useState(null) const [theme, setTheme] = useState(DEFAULT_THEME) @@ -67,12 +95,12 @@ export function App({ gw }: { gw: GatewayClient }) { const [lastUserMsg, setLastUserMsg] = useState('') const [queueEditIdx, setQueueEditIdx] = useState(null) const [historyIdx, setHistoryIdx] = useState(null) - const [scrollOffset, setScrollOffset] = useState(0) + const [streaming, setStreaming] = useState('') const [queuedDisplay, setQueuedDisplay] = useState([]) const [catalog, setCatalog] = useState(null) const buf = useRef('') - const stickyRef = useRef(true) + const interruptedRef = useRef(false) const queueRef = useRef([]) const historyRef = useRef(inputHistory.load()) const historyDraftRef = useRef('') @@ -81,6 +109,7 @@ export function App({ gw }: { gw: GatewayClient }) { const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const stderrWarnedRef = useRef(false) + const pasteCounterRef = useRef(0) const empty = !messages.length const blocked = !!(clarify || approval || sudo || secret || picker) @@ -119,12 +148,6 @@ export function App({ gw }: { gw: GatewayClient }) { } } - useEffect(() => { - if (stickyRef.current) { - setScrollOffset(0) - } - }, [messages.length]) - useEffect(() => { if (!sid || !stdout) { return @@ -138,55 +161,52 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps - const paletteMatches = useMemo( - () => (!blocked && input.startsWith('/') ? paletteForLine(input, catalog) : []), - [blocked, catalog, input] - ) + const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) + const [compIdx, setCompIdx] = useState(0) + const [compReplace, setCompReplace] = useState(0) + const compInputRef = useRef('') - const queueRows = useMemo( - () => estimateQueuedRows(queuedDisplay.length, queueEditIdx), - [queueEditIdx, queuedDisplay.length] - ) + useEffect(() => { + if (blocked) { if (completions.length) { setCompletions([]); setCompIdx(0) }; return } + if (input === compInputRef.current) return + compInputRef.current = input - const thinkingRows = thinking ? Math.max(1, tools.length || 1) + (reasoning || thinkingText ? 1 : 0) : 0 - const paletteRows = paletteMatches.length ? paletteMatches.length + 1 : 0 - const footerRows = statusBar ? 1 : 0 - const msgBudget = Math.max(3, rows - 2 - (empty ? 0 : 2) - thinkingRows - queueRows - paletteRows - footerRows - 2) + const isSlash = input.startsWith('/') + const pathWord = !isSlash ? extractTabWord(input) : null - const viewport = useMemo(() => { - if (!messages.length) { - return { above: 0, end: 0, start: 0 } + if (!isSlash && !pathWord) { + if (completions.length) { setCompletions([]); setCompIdx(0) } + return } - const end = Math.max(0, messages.length - scrollOffset) - const width = Math.max(20, cols - 5) + const t = setTimeout(() => { + if (compInputRef.current !== input) return - let budget = msgBudget - let start = end + const req = isSlash + ? gw.request('complete.slash', { text: input }) + : gw.request('complete.path', { word: pathWord }) - for (let i = end - 1; i >= 0 && budget > 0; i--) { - const msg = messages[i]! - const margin = msg.role === 'user' && i > 0 && messages[i - 1]?.role !== 'user' ? 1 : 0 - const text = msg.role === 'user' ? userDisplay(msg.text) : msg.text - budget -= margin + estimateRows(text, width, compact && msg.role === 'assistant') + req.then((r: any) => { + if (compInputRef.current !== input) return + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + }).catch(() => {}) + }, 60) - if (budget >= 0) { - start = i - } - } + return () => clearTimeout(t) + }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps - if (start === end && end > 0) { - start = end - 1 - } + const appendMessage = useCallback((msg: Msg) => { + setMessages(prev => [...prev, msg]) + setHistoryItems(prev => [...prev, msg]) + }, []) - if (start > 0 && messages[start - 1]?.role === 'user') { - start-- - } + const appendHistory = useCallback((msg: Msg) => { + setHistoryItems(prev => [...prev, msg]) + }, []) - return { above: start, end, start } - }, [cols, compact, messages, msgBudget, scrollOffset]) - - const sys = useCallback((text: string) => setMessages(prev => [...prev, { role: 'system' as const, text }]), []) + const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) const colsRef = useRef(cols) colsRef.current = cols @@ -214,11 +234,18 @@ export function App({ gw }: { gw: GatewayClient }) { protocolWarnedRef.current = false stderrWarnedRef.current = false + if (r.info) { + setInfo(r.info) + appendHistory(introMsg(r.info)) + } else { + setInfo(null) + } + if (msg) { sys(msg) } }), - [rpc, sys] + [appendHistory, rpc, sys] ) const idle = () => { @@ -231,6 +258,8 @@ export function App({ gw }: { gw: GatewayClient }) { setSecret(null) setReasoning('') setThinkingText('') + setStreaming('') + buf.current = '' } const die = () => { @@ -246,36 +275,28 @@ export function App({ gw }: { gw: GatewayClient }) { historyDraftRef.current = '' } - const scrollBot = () => { - setScrollOffset(0) - stickyRef.current = true - } - - 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 v = Math.max(0, prev - n) - - if (!v) { - stickyRef.current = true - } - - return v + const expandPastes = (text: string) => + text.replace(PASTE_REF_RE, (m, path) => { + try { return readFileSync(path, 'utf8') } catch { return m } }) + + const collapsePaste = (text: string) => { + pasteCounterRef.current += 1 + const lineCount = text.split('\n').length + const pasteDir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'pastes') + mkdirSync(pasteDir, { recursive: true }) + const pasteFile = join(pasteDir, `paste_${pasteCounterRef.current}_${new Date().toTimeString().slice(0, 8).replace(/:/g, '')}.txt`) + writeFileSync(pasteFile, text, 'utf8') + return `[Pasted text #${pasteCounterRef.current}: ${lineCount} lines → ${pasteFile}]` } const send = (text: string) => { setLastUserMsg(text) - setMessages(prev => [...prev, { role: 'user', text }]) - scrollBot() - setStatus('thinking…') + appendMessage({ role: 'user', text }) setBusy(true) buf.current = '' - gw.request('prompt.submit', { session_id: sid, text }).catch((e: Error) => { + interruptedRef.current = false + gw.request('prompt.submit', { session_id: sid, text: expandPastes(text) }).catch((e: Error) => { sys(`error: ${e.message}`) setStatus('ready') setBusy(false) @@ -283,7 +304,7 @@ export function App({ gw }: { gw: GatewayClient }) { } const shellExec = (cmd: string) => { - setMessages(prev => [...prev, { role: 'user', text: `!${cmd}` }]) + appendMessage({ role: 'user', text: `!${cmd}` }) setBusy(true) setStatus('running…') gw.request('shell.exec', { command: cmd }) @@ -377,25 +398,14 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (!inputBuf.length && key.tab && input.startsWith('/')) { - const next = tabAdvance(input, catalog) - - if (next) { - setInput(next) - } - + if (completions.length && input && (key.upArrow || key.downArrow)) { + setCompIdx(i => key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length) return } - if (key.pageUp) { - scrollUp(5) - - return - } - - if (key.pageDown) { - scrollDown(5) - + if (!inputBuf.length && key.tab && completions.length) { + const pick = completions[compIdx] + if (pick) setInput(input.slice(0, compReplace) + pick.text) return } @@ -447,7 +457,11 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.ctrl && ch === 'c') { if (busy && sid) { + interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + if (buf.current.trim()) { + appendMessage({ role: 'assistant' as const, text: buf.current.trimStart() }) + } idle() setStatus('interrupted') sys('interrupted by user') @@ -465,18 +479,11 @@ export function App({ gw }: { gw: GatewayClient }) { die() } - if (key.ctrl && ch === 'l') { - setMessages([]) - } if (key.ctrl && ch === 'g') { return openEditor() } - if (key.ctrl && ch === 'v') { - return paste() - } - if (key.escape) { clearIn() } @@ -513,7 +520,6 @@ export function App({ gw }: { gw: GatewayClient }) { case 'session.info': setInfo(p as SessionInfo) - break case 'thinking.delta': @@ -528,7 +534,6 @@ export function App({ gw }: { gw: GatewayClient }) { setBusy(true) setReasoning('') setThinkingText('') - setStatus('thinking…') break @@ -545,11 +550,6 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'gateway.stderr': - if (!stderrWarnedRef.current) { - stderrWarnedRef.current = true - sys('gateway stderr captured · /logs to inspect') - } - break case 'gateway.protocol_error': @@ -570,33 +570,34 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.generating': - if (p?.name) { - setStatus(`preparing ${p.name}…`) - } - break case 'tool.progress': if (p?.preview) { - setMessages(prev => - prev.at(-1)?.role === 'tool' - ? [...prev.slice(0, -1), { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] - : [...prev, { role: 'tool' as const, text: `${p.name}: ${p.preview}` }] - ) + setTools(prev => { + const idx = prev.findIndex(t => t.name === p.name) + if (idx >= 0) return [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] + return prev + }) } 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}…` }]) - + case 'tool.start': { + const ctx = (p.context as string) || '' + setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: ctx }]) + appendMessage({ role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}${ctx ? ' ' + ctx : ''}` }) break + } case 'tool.complete': - setTools(prev => prev.filter(t => t.id !== p.tool_id)) - + setTools(prev => { + const done = prev.find(t => t.id === p.tool_id) + const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name + const ctx = done?.context || '' + appendMessage({ role: 'tool', text: `${label}${ctx ? ' ' + ctx : ''} ✓` }) + return prev.filter(t => t.id !== p.tool_id) + }) break case 'clarify.request': @@ -634,20 +635,16 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.delta': - if (!p?.text) { - break - } + if (!p?.text || interruptedRef.current) break buf.current += p.rendered ?? p.text - setThinking(false) - setTools([]) - setReasoning('') - setMessages(prev => upsert(prev, 'assistant', buf.current.trimStart())) + setStreaming(buf.current.trimStart()) break case 'message.complete': { idle() - setMessages(prev => upsert(prev, 'assistant', (p?.rendered ?? p?.text ?? buf.current).trimStart())) + setStreaming('') + appendMessage({ role: 'assistant' as const, text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) buf.current = '' setStatus('ready') @@ -667,8 +664,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (next) { setLastUserMsg(next) - setMessages(prev => [...prev, { role: 'user' as const, text: next }]) - setStatus('thinking…') + appendMessage({ role: 'user' as const, text: next }) setBusy(true) buf.current = '' gw.request('prompt.submit', { session_id: ev.session_id, text: next }).catch((e: Error) => { @@ -690,7 +686,7 @@ export function App({ gw }: { gw: GatewayClient }) { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [gw, sys, newSession] + [appendMessage, gw, sys, newSession] ) useEffect(() => { @@ -715,7 +711,6 @@ export function App({ gw }: { gw: GatewayClient }) { const rows = catalog?.pairs ?? [] const cap = 52 const lines = rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`) - sys( [ ' Commands:', @@ -728,717 +723,108 @@ export function App({ gw }: { gw: GatewayClient }) { .filter(Boolean) .join('\n') ) - return true } + case 'quit': + case 'exit': + case 'q': + die() + return true + case 'clear': setStatus('forging session…') newSession() - - return true - - case 'quit': // falls through - - case 'exit': - die() - return true case 'new': setStatus('forging session…') newSession('new session started') - - return true - - case 'branch': // falls through - - case 'fork': - if (!sid) { - return true - } - - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - setSid(r.session_id) - setUsage(ZERO) - sys(`branched → ${r.title}`) - setStatus('ready') - }) - - return true - - case 'undo': - if (!sid) { - return true - } - - rpc('session.undo', { session_id: sid }).then((r: any) => { - if (r.removed > 0) { - setMessages(prev => { - const q = [...prev] - - 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') - } - }) - - return true - - case 'retry': - if (!lastUserMsg) { - sys('nothing to retry') - - return true - } - - if (sid) { - gw.request('session.undo', { session_id: sid }).catch(() => {}) - } - - setMessages(prev => { - const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - return q - }) - send(lastUserMsg) - return true case 'compact': setCompact(c => (arg ? true : !c)) sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) - return true - case 'compress': - if (!sid) { - return true - } - - rpc('session.compress', { session_id: sid }).then((r: any) => { - sys('context compressed') - - if (r.usage) { - setUsage(r.usage) - } - }) - + case 'resume': + if (!sid) { setPicker(true); return true } + setPicker(true) return true - case 'cost': // falls through - - case 'usage': - sys( - `in: ${fmtK(usage.input)} out: ${fmtK(usage.output)} total: ${fmtK(usage.total)} calls: ${usage.calls}` - ) - - return true case 'copy': { const all = messages.filter(m => m.role === 'assistant') const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.length - 1] - - if (!target) { - sys('nothing to copy') - - return true - } - + if (!target) { sys('nothing to copy'); return true } writeOsc52Clipboard(target.text) sys('copied to clipboard') - return true } - case 'context': { - const pct = Math.min(100, Math.round((usage.total / MAX_CTX) * 100)) - const bar = Math.round((pct / 100) * 30) - const icon = pct < 50 ? '✓' : pct < 80 ? '⚠' : '✗' - sys( - `context: ${fmtK(usage.total)} / ${fmtK(MAX_CTX)} (${pct}%)\n[${'█'.repeat(bar)}${'░'.repeat(30 - bar)}] ${icon}` - ) - - 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 'logs': { - const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20)) - const out = gw.getLogTail(limit) - - sys(out || 'no gateway logs') - - return true - } - - case 'resume': - setPicker(true) - - return true - - case 'history': - if (!sid) { - setPicker(true) - - return true - } - - rpc('session.history', { session_id: sid }).then((r: any) => - sys(`session ${sid}: ${r.count} messages in context`) - ) - - return true - - case 'title': - if (!sid) { - return true - } - - if (!arg) { - rpc('session.title', { session_id: sid }).then((r: any) => - sys(`title: ${r.title || '(none)'} session: ${r.session_key}`) - ) - - return true - } - - rpc('session.title', { session_id: sid, title: arg }).then(() => sys(`title → ${arg}`)) - - return true - - case 'tools': - if (!info?.tools || !Object.keys(info.tools).length) { - sys('no tools loaded') - - return true - } - - sys( - Object.entries(info.tools) - .map(([k, vs]) => `${k} (${vs.length}): ${vs.join(', ')}`) - .join('\n') - ) - - return true - - case 'skills': - if (!arg || arg === 'list') { - 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 - } - - if (arg.startsWith('search ')) { - rpc('skills.manage', { action: 'search', query: arg.slice(7).trim() }).then((r: any) => { - if (!r.results?.length) { - sys('no results') - - return - } - - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) - - return true - } - - if (arg.startsWith('install ')) { - rpc('skills.manage', { action: 'install', query: arg.slice(8).trim() }).then((r: any) => - sys(r.installed ? `installed ${r.name}` : 'install failed') - ) - - return true - } - - if (arg === 'browse' || arg.startsWith('browse ')) { - rpc('skills.manage', { action: 'browse', query: arg.slice(6).trim() }).then((r: any) => { - if (!r.results?.length) { - sys('no skills available') - - return - } - - sys(r.results.map((s: any) => ` ${s.name}: ${s.description}`).join('\n')) - }) - - return true - } - - if (arg.startsWith('inspect ')) { - rpc('skills.manage', { action: 'inspect', query: arg.slice(8).trim() }).then((r: any) => - sys(JSON.stringify(r.info, null, 2)) - ) - - return true - } - - sys('usage: /skills [list|search |install |browse|inspect ]') - - return true - - case 'verbose': - rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose → ${r.value}`)) - - return true - - case 'yolo': - rpc('config.set', { key: 'yolo', value: '' }).then((r: any) => - sys(`yolo → ${r.value === '1' ? 'on' : 'off'}`) - ) - - return true - - case 'reasoning': - if (!arg) { - sys('usage: /reasoning ') - - return true - } - - rpc('config.set', { key: 'reasoning', value: arg }).then((r: any) => sys(`reasoning → ${r.value}`)) - - return true - - case 'stop': - rpc('process.stop').then((r: any) => sys(`killed ${r.killed} process(es)`)) - - return true - - case 'profile': - gw.request('config.get', { key: 'profile' }) - .then((r: any) => sys(`profile: ${r.display}`)) - .catch(() => sys(`profile: ${process.env.HERMES_HOME ?? '~/.hermes'}`)) - - return true - - case 'save': - if (!sid) { - return true - } - - rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved to ${r.file}`)) - - return true - - case 'provider': - rpc('config.get', { key: 'provider' }).then((r: any) => { - const lines = [`model: ${r.model} provider: ${r.provider}`] - - if (r.providers?.length) { - lines.push(`available: ${r.providers.join(', ')}`) - } - - sys(lines.join('\n')) - }) - - return true - - case 'prompt': - if (!arg) { - rpc('config.get', { key: 'prompt' }).then((r: any) => sys(`custom prompt: ${r.prompt || '(none set)'}`)) - - return true - } - - rpc('config.set', { key: 'prompt', value: arg }).then((r: any) => - sys(r.value ? `prompt set (${r.value.length} chars)` : 'prompt cleared') - ) - - return true - - case 'personality': - if (!arg) { - sys('usage: /personality (concise, creative, analytical, friendly, none)') - - return true - } - - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => - sys(`personality → ${r.value || 'default'}`) - ) - - return true - - case 'plan': - send(arg ? `/plan ${arg}` : 'Create a detailed plan for the current task.') - - return true - - case 'background': - - case 'bg': - if (!arg) { - sys('usage: /background ') - - return true - } - - rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => - sys(`background task ${r.task_id} started`) - ) - - return true - - case 'btw': - if (!arg) { - sys('usage: /btw ') - - return true - } - - rpc('prompt.btw', { session_id: sid, text: arg }).then(() => sys('btw running…')) - - return true - - case 'queue': // falls through - - case 'q': - if (!arg) { - sys(`${queueRef.current.length} queued message(s)`) - - return true - } - - enqueue(arg) - sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) - - return true - - case 'rollback': - if (!sid) { - return true - } - - if (!arg) { - rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r.enabled) { - sys('checkpoints not enabled — use hermes --checkpoints') - - return - } - - if (!r.checkpoints?.length) { - sys('no checkpoints') - - return - } - - sys( - r.checkpoints - .map((c: any, i: number) => ` ${i + 1}. ${c.message || c.hash.slice(0, 8)} (${c.timestamp})`) - .join('\n') - ) - }) - - return true - } - - if (arg.startsWith('diff ')) { - const ref = arg.slice(5).trim() - rpc('rollback.list', { session_id: sid }).then((r: any) => { - const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - - if (!hash) { - sys(`checkpoint ${ref} not found`) - - return - } - - rpc('rollback.diff', { session_id: sid, hash }).then((d: any) => - sys(d.rendered || d.stat || d.diff || 'no changes') - ) - }) - - return true - } - - { - const parts = arg.trim().split(/\s+/) - const ref = parts[0]! - const file = parts[1] - rpc('rollback.list', { session_id: sid }).then((r: any) => { - const hash = /^\d+$/.test(ref) ? r.checkpoints?.[parseInt(ref) - 1]?.hash : ref - - if (!hash) { - sys(`checkpoint ${ref} not found`) - - return - } - - rpc('rollback.restore', { session_id: sid, hash, ...(file ? { file } : {}) }).then((d: any) => - sys(d.success ? `restored${file ? ` ${file}` : ''}` : `failed: ${d.error || 'unknown'}`) - ) - }) - } - - return true - - case 'insights': - rpc('insights.get', { days: arg ? parseInt(arg) : 30 }).then((r: any) => - sys(`last ${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) - ) - - return true - - case 'toolsets': - if (!info?.tools) { - sys('no toolsets loaded') - - return true - } - - sys( - Object.entries(info.tools) - .map(([k, vs]) => `${k}: ${vs.length} tools`) - .join('\n') - ) - - return true - case 'paste': paste() - return true - case 'reload-mcp': - - case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP servers reloaded')) - - return true - - case 'browser': - if (!arg || arg === 'status') { - rpc('browser.manage', { action: 'status' }).then((r: any) => - sys(r.connected ? `browser: connected (${r.url})` : 'browser: not connected') - ) - } else if (arg === 'connect' || arg.startsWith('connect ')) { - const url = arg.split(/\s+/)[1] - rpc('browser.manage', { action: 'connect', ...(url ? { url } : {}) }).then((r: any) => - sys(`browser connected: ${r.url}`) - ) - } else if (arg === 'disconnect') { - rpc('browser.manage', { action: 'disconnect' }).then(() => sys('browser disconnected')) - } else { - sys('usage: /browser [connect|disconnect|status]') - } - - return true - - case 'platforms': - - case 'gateway': - sys('gateway status is not available in TUI mode') - - return true - - case 'statusbar': - - case 'sb': - setStatusBar(v => !v) - sys(`status bar ${statusBar ? 'off' : 'on'}`) - - return true - - case 'voice': - if (!arg || arg === 'status') { - rpc('voice.toggle', { action: 'status' }).then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) - } else if (arg === 'on' || arg === 'off') { - rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice → ${r.enabled ? 'on' : 'off'}`)) - } else if (arg === 'record') { - rpc('voice.record', { action: 'start' }).then(() => sys('recording… (use /voice stop to transcribe)')) - } else if (arg === 'stop') { - rpc('voice.record', { action: 'stop' }).then((r: any) => { - if (r.text) { - send(r.text) - } else { - sys('no speech detected') - } - }) - } else if (arg === 'tts') { - const last = messages.filter(m => m.role === 'assistant').at(-1) - - if (last) { - rpc('voice.tts', { text: last.text }).then(() => sys('speaking…')) - } else { - sys('no response to speak') - } - } else { - sys('usage: /voice [on|off|status|record|stop|tts]') - } - - return true - - case 'plugins': - rpc('plugins.list').then((r: any) => { - if (!r.plugins?.length) { - sys('no plugins installed') - - return - } - - sys(r.plugins.map((p: any) => ` ${p.name} v${p.version} ${p.enabled ? '✓' : '✗'}`).join('\n')) - }) - - return true - - case 'cron': - if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }).then((r: any) => { - const jobs = r.jobs || r.schedules || [] - - if (!jobs.length) { - sys('no cron jobs') - - return - } - - sys(jobs.map((j: any) => ` ${j.name}: ${j.schedule} ${j.paused ? '(paused)' : ''}`).join('\n')) - }) - } else { - const parts = arg.split(/\s+/) - const sub = parts[0]! - - if (sub === 'add' || sub === 'create') { - const name = parts[1] || '' - const schedule = parts[2] || '' - const prompt = parts.slice(3).join(' ') - rpc('cron.manage', { action: 'add', name, schedule, prompt }).then((r: any) => - sys(r.message || r.status || 'created') - ) - } else { - rpc('cron.manage', { action: sub, name: parts[1] || '' }).then((r: any) => - sys(r.message || r.status || JSON.stringify(r)) - ) - } - } - - return true - - case 'update': - case 'hermes': { - const argv = name === 'update' ? ['update'] : arg.split(/\s+/).filter(Boolean) - - if (!argv.length) { - sys('usage: /hermes (e.g. sessions list, chat -q "hi")') - - return true - } - - if (name === 'update') { - setBusy(true) - setStatus('updating…') - } - - rpc('cli.exec', { argv, timeout: name === 'update' ? 600 : 240 }) - .then((r: any) => { - if (r.blocked) { - return sys(r.hint ?? 'blocked') - } - - sys(r.output ?? '(no output)') - - if (r.code !== 0) { - sys(`exit ${r.code}`) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - .finally(() => { - if (name === 'update') { - setStatus('ready') - setBusy(false) - } - }) - + case 'logs': { + const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20)) + sys(gw.getLogTail(limit) || 'no gateway logs') return true } - case 'model': - if (!arg) { - sys('usage: /model ') - - return true - } - - rpc('config.set', { key: 'model', value: arg }).then(() => sys(`model → ${arg}`)) - + case 'statusbar': + case 'sb': + setStatusBar(v => !v) + sys(`status bar ${statusBar ? 'off' : 'on'}`) return true - case 'skin': - if (!arg) { - sys('usage: /skin ') + case 'queue': + if (!arg) { sys(`${queueRef.current.length} queued message(s)`); return true } + enqueue(arg) + sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + return true - return true - } - - rpc('config.set', { key: 'skin', value: arg }).then(() => sys(`skin → ${arg} (restart to apply)`)) + case 'undo': + if (!sid) return true + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (r.removed > 0) { + setMessages(prev => { + const q = [...prev] + 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') + }) + return true + case 'retry': + if (!lastUserMsg) { sys('nothing to retry'); return true } + if (sid) gw.request('session.undo', { session_id: sid }).catch(() => {}) + setMessages(prev => { + const q = [...prev] + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() + return q + }) + send(lastUserMsg) return true default: - gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) + rpc('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => { - if (r.type === 'exec') { - sys(r.output || '(no output)') - } else if (r.type === 'alias') { - slash(`/${r.target}${arg ? ' ' + arg : ''}`) - } else if (r.type === 'plugin') { - sys(r.output || '(no output)') - } else if (r.type === 'skill') { - sys(`⚡ loading skill: ${r.name}`) - send(r.message) - } + if (r?.output) sys(r.output) + else sys(`/${name}: no output`) }) .catch(() => { - gw.request('command.resolve', { name: name ?? '' }) - .then((r: any) => { - if (!r.canonical || r.canonical === name) { - sys(`unknown command: /${name}`) - } else if (r.category === 'cli-only') { - sys(`/${name} is CLI-only — run it in a terminal`) - } else { - send(`/${r.canonical}${arg ? ' ' + arg : ''}`) - } + gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) + .then((d: any) => { + if (d.type === 'exec') sys(d.output || '(no output)') + else if (d.type === 'alias') slash(`/${d.target}${arg ? ' ' + arg : ''}`) + else if (d.type === 'plugin') sys(d.output || '(no output)') + else if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`); send(d.message) } }) .catch(() => sys(`unknown command: /${name}`)) }) - return true } }, @@ -1495,8 +881,23 @@ export function App({ gw }: { gw: GatewayClient }) { if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { replaceQ(editIdx, full) + const picked = queueRef.current.splice(editIdx, 1)[0] + syncQueue() setQueueEdit(null) + if (picked && busy && sid) { + queueRef.current.unshift(picked) + syncQueue() + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + setStatus('interrupting…') + return + } + + if (picked && sid) { + send(picked) + return + } + return } @@ -1551,84 +952,39 @@ export function App({ gw }: { gw: GatewayClient }) { : theme.color.dim 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) - - )} + + + {(m, i) => ( + + {m.kind === 'intro' && m.info + ? ( + + + + + ) + : ( + + )} + + )} + + + + {streaming && ( + + )} - - {viewport.above > 0 && ( - - ↑ {viewport.above} above · PgUp/PgDn to scroll - - )} - - {messages.slice(viewport.start, viewport.end).map((m, i) => { - const ri = viewport.start + i - - return ( - 0 && messages[ri - 1]!.role !== 'user' ? 1 : 0} - > - - - ) - })} - - {scrollOffset > 0 && ( - - ↓ {scrollOffset} below · PgDn or Enter to return - - )} - - {thinking && } - + {(thinking || tools.length > 0) && !streaming && } {clarify && ( { gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) - setMessages(prev => [...prev, { role: 'user', text: answer }]) + appendMessage({ role: 'user', text: answer }) setClarify(null) - setStatus('thinking…') }} req={clarify} t={theme} @@ -1686,6 +1042,8 @@ export function App({ gw }: { gw: GatewayClient }) { .then((r: any) => { setSid(r.session_id) setMessages([]) + setInfo(r.info ?? null) + if (r.info) appendHistory(introMsg(r.info)) setUsage(ZERO) lastStatusNoteRef.current = '' protocolWarnedRef.current = false @@ -1702,51 +1060,45 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} - {!!paletteMatches.length && } - - {statusBar && ( - - {status} - {' · '} - {[ - sid && `session ${sid}`, - info?.model?.split('/').pop(), - queuedDisplay.length && `queue ${queuedDisplay.length}`, - usage.total > 0 && `${fmtK(usage.total)} tok` - ] - .filter(Boolean) - .join(' · ')} - - )} + {' '} - {'─'.repeat(cols - 2)} + 0 && `${fmtK(usage.total)} tok`]} /> {!blocked && ( - - {inputBuf.length ? '… ' : `${theme.brand.prompt} `} - + {inputBuf.length ? '… ' : `${theme.brand.prompt} `} + )} + + {!!completions.length && ( + + {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { + const active = Math.max(0, compIdx - 8) + i === compIdx + return ( + + {item.display} + {item.meta ? {item.meta} : null} + + ) + })} + + )} + + {!empty && !sid && ⚕ {status}} - + ) } diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index ba46d0147d..45214accb0 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -29,20 +29,19 @@ export function Banner({ t }: { t: Theme }) { {t.brand.icon} NOUS HERMES )} - - - {t.brand.icon} Nous Research - · Messenger of the Digital Gods - + {t.brand.icon} Nous Research · Messenger of the Digital Gods ) } -export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { +export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) { const cols = useStdout().stdout?.columns ?? 100 const wide = cols >= 90 - const w = wide ? cols - 46 : cols - 10 + const leftW = wide ? 34 : 0 + const w = wide ? cols - leftW - 12 : cols - 10 + const cwd = info.cwd || process.cwd() const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) + const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}` const truncLine = (pfx: string, items: string[]) => { let line = '' @@ -60,7 +59,7 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { return line } - const section = (title: string, data: Record, max = 8) => { + const section = (title: string, data: Record, max = 8, overflowLabel = 'more…') => { const entries = Object.entries(data).sort() const shown = entries.slice(0, max) const overflow = entries.length - max @@ -76,7 +75,7 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { {truncLine(strip(k) + ': ', vs)} ))} - {overflow > 0 && (and {overflow} more…)} + {overflow > 0 && (and {overflow} {overflowLabel})} ) } @@ -84,17 +83,22 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { return ( {wide && ( - + - Nous Research + + {info.model.split('/').pop()} + · Nous Research + + {cwd} + {sid && Session: {sid}} )} - - {t.brand.icon} {t.brand.name} - - {section('Tools', info.tools)} + + {title} + + {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} @@ -103,10 +107,14 @@ export function SessionPanel({ info, t }: { info: SessionInfo; t: Theme }) { {' · '} /help for commands - - {info.model.split('/').pop()} - {' · '}Ctrl+C to interrupt - + {typeof info.update_behind === 'number' && info.update_behind > 0 && ( + + ⚠ {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind + — run + {info.update_command || 'hermes update'} + to update + + )} ) diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx index 810dc01007..2dad8c04d2 100644 --- a/ui-tui/src/components/commandPalette.tsx +++ b/ui-tui/src/components/commandPalette.tsx @@ -8,13 +8,12 @@ export function CommandPalette({ matches, t }: { matches: [string, string][]; t: } return ( - + {matches.map(([cmd, desc], i) => ( {cmd} - {desc ? — {desc} : null} ))} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index d7b3df17f2..26810a8dd6 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -70,6 +70,21 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const lines = text.split('\n') const nodes: ReactNode[] = [] let i = 0 + let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'table' | null = null + + const gap = () => { + if (nodes.length && prevKind !== 'blank') { + nodes.push({' '}) + prevKind = 'blank' + } + } + + const start = (kind: Exclude) => { + if (prevKind && prevKind !== 'blank' && prevKind !== kind) { + gap() + } + prevKind = kind + } while (i < lines.length) { const line = lines[i]! @@ -81,7 +96,15 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } + if (!line.trim()) { + gap() + i++ + + continue + } + if (line.startsWith('```')) { + start('code') const lang = line.slice(3).trim() const block: string[] = [] @@ -115,6 +138,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const heading = line.match(/^#{1,3}\s+(.*)/) if (heading) { + start('heading') nodes.push( {heading[1]} @@ -128,6 +152,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const bullet = line.match(/^\s*[-*]\s(.*)/) if (bullet) { + start('list') nodes.push( @@ -142,6 +167,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const numbered = line.match(/^\s*(\d+)\.\s(.*)/) if (numbered) { + start('list') nodes.push( {numbered[1]}. @@ -154,6 +180,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st } if (line.match(/^>\s?/)) { + start('quote') const quoteLines: string[] = [] while (i < lines.length && lines[i]!.match(/^>\s?/)) { @@ -176,6 +203,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st } if (line.includes('|') && line.trim().startsWith('|')) { + start('table') const tableRows: string[][] = [] while (i < lines.length && lines[i]!.trim().startsWith('|')) { @@ -210,7 +238,9 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } + start('paragraph') nodes.push() + i++ } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 5b9f4659a5..6e59f10609 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -5,47 +5,52 @@ import { LONG_MSG, ROLE } from '../constants.js' import { hasAnsi, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg } from '../types.js' - import { Md } from './markdown.js' -export const MessageLine = memo(function MessageLine({ compact, msg, t }: { compact?: boolean; msg: Msg; t: Theme }) { +export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: { cols: number; compact?: boolean; msg: Msg; t: Theme }) { const { body, glyph, prefix } = ROLE[msg.role](t) + const contentWidth = Math.max(20, cols - 5) + + if (msg.role === 'tool') { + return ( + + {' '}{msg.text} + + ) + } const content = (() => { - if (msg.role === 'assistant') { - if (hasAnsi(msg.text)) { - return {msg.text} - } - - return - } + if (msg.role === 'assistant') + return hasAnsi(msg.text) ? {msg.text} : if (msg.role === 'user' && msg.text.length > LONG_MSG) { - const displayed = userDisplay(msg.text) - const [head, ...rest] = displayed.split('[long message]') + const [head, ...rest] = userDisplay(msg.text).split('[long message]') return ( {head} - - [long message] - + [long message] {rest.join('')} ) } - return {msg.text} + return {msg.text} })() return ( - - - - {glyph}{' '} - + + {(msg.role === 'user' || msg.role === 'assistant') && {' '}} + + + + {glyph} + + + + {content} + - {content} ) }) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx new file mode 100644 index 0000000000..63a19750fe --- /dev/null +++ b/ui-tui/src/components/textInput.tsx @@ -0,0 +1,128 @@ +import { Text, useInput } from 'ink' +import { useEffect, useRef, useState } from 'react' + +function wl(s: string, p: number) { + let i = p - 1 + while (i > 0 && /\s/.test(s[i]!)) i-- + while (i > 0 && !/\s/.test(s[i - 1]!)) i-- + return Math.max(0, i) +} + +function wr(s: string, p: number) { + let i = p + while (i < s.length && !/\s/.test(s[i]!)) i++ + while (i < s.length && /\s/.test(s[i]!)) i++ + return i +} + +const ESC = String.fromCharCode(0x1b) +const INV = ESC + '[7m' +const INV_OFF = ESC + '[27m' +const DIM = ESC + '[2m' +const DIM_OFF = ESC + '[22m' +const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ +const BRACKET_PASTE = /\x1b\[20[01]~/g + +interface Props { + value: string + onChange: (v: string) => void + onSubmit?: (v: string) => void + onLargePaste?: (text: string) => string + placeholder?: string + focus?: boolean +} + +export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder = '', focus = true }: Props) { + const [cur, setCur] = useState(value.length) + const vRef = useRef(value) + const selfChange = useRef(false) + const pasteBuf = useRef('') + const pasteTimer = useRef | null>(null) + const pastePos = useRef(0) + vRef.current = value + + useEffect(() => { + if (selfChange.current) { selfChange.current = false } else { setCur(value.length) } + }, [value]) + + const flushPaste = () => { + const pasted = pasteBuf.current + const at = pastePos.current + pasteBuf.current = '' + pasteTimer.current = null + if (!pasted) return + + const v = vRef.current + if (pasted.split('\n').length >= 5 || pasted.length > 500) { + const ph = onLargePaste?.(pasted) ?? pasted.replace(/\n/g, ' ') + const nv = v.slice(0, at) + ph + v.slice(at) + selfChange.current = true + onChange(nv) + setCur(at + ph.length) + } else { + const clean = pasted.replace(/\n/g, ' ') + if (clean.length && PRINTABLE.test(clean)) { + const nv = v.slice(0, at) + clean + v.slice(at) + selfChange.current = true + onChange(nv) + setCur(at + clean.length) + } + } + } + + useInput( + (inp, k) => { + if (k.upArrow || k.downArrow || (k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) + return + if (k.return) { onSubmit?.(value); return } + + let c = cur, v = value + const mod = k.ctrl || k.meta + + if (k.home || (k.ctrl && inp === 'a')) c = 0 + else if (k.end || (k.ctrl && inp === 'e')) c = v.length + else if (k.leftArrow) c = mod ? wl(v, c) : Math.max(0, c - 1) + else if (k.rightArrow) c = mod ? wr(v, c) : Math.min(v.length, c + 1) + else if ((k.backspace || k.delete) && c > 0) { + if (mod) { const t = wl(v, c); v = v.slice(0, t) + v.slice(c); c = t } + else { v = v.slice(0, c - 1) + v.slice(c); c-- } + } + else if (k.ctrl && inp === 'w' && c > 0) { const t = wl(v, c); v = v.slice(0, t) + v.slice(c); c = t } + else if (k.ctrl && inp === 'u') { v = v.slice(c); c = 0 } + else if (k.ctrl && inp === 'k') v = v.slice(0, c) + else if (k.meta && inp === 'b') c = wl(v, c) + else if (k.meta && inp === 'f') c = wr(v, c) + else if (inp.length > 0) { + const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') + if (!raw) return + + const isMultiChar = raw.length > 1 || raw.includes('\n') + + if (isMultiChar) { + if (!pasteBuf.current) pastePos.current = c + pasteBuf.current += raw + if (pasteTimer.current) clearTimeout(pasteTimer.current) + pasteTimer.current = setTimeout(flushPaste, 50) + return + } + + if (PRINTABLE.test(raw)) { v = v.slice(0, c) + raw + v.slice(c); c += raw.length } + else return + } + else return + + c = Math.max(0, Math.min(c, v.length)) + setCur(c) + if (v !== value) { selfChange.current = true; onChange(v) } + }, + { isActive: focus } + ) + + if (!focus) return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} + if (!value && placeholder) return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} + + let r = '' + for (let i = 0; i < value.length; i++) r += i === cur ? INV + value[i] + INV_OFF : value[i] + if (cur === value.length) r += INV + ' ' + INV_OFF + return {r} +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 71e3ebf4f3..e5f13992d1 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,64 +1,53 @@ import { Text } from 'ink' -import { memo, useEffect, useRef, useState } from 'react' +import { memo, useEffect, useState } from 'react' import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js' import { pick } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -function SpinnerChar({ color }: { color: string }) { - const ref = useRef(0) +function Spinner({ color }: { color: string }) { + const [i, setI] = useState(0) useEffect(() => { - const id = setInterval(() => { - ref.current = (ref.current + 1) % SPINNER.length - }, 80) - + const id = setInterval(() => setI(p => (p + 1) % SPINNER.length), 80) return () => clearInterval(id) }, []) - return {SPINNER[ref.current]} + return {SPINNER[i]} } export const Thinking = memo(function Thinking({ - reasoning, - t, - thinking, - tools + reasoning, t, tools }: { - reasoning: string - t: Theme - thinking?: string - tools: ActiveTool[] + reasoning: string; t: Theme; tools: ActiveTool[] }) { - const [verb] = useState(() => pick(VERBS)) - const [face] = useState(() => pick(FACES)) + const [verb, setVerb] = useState(() => pick(VERBS)) + const [face, setFace] = useState(() => pick(FACES)) - const tail = (reasoning || thinking || '').slice(-120).replace(/\n/g, ' ') + useEffect(() => { + const id = setInterval(() => { setVerb(pick(VERBS)); setFace(pick(FACES)) }, 1100) + return () => clearInterval(id) + }, []) - if (tools.length) { - return ( - <> - {tools.map(tool => ( - - ⚡ {TOOL_VERBS[tool.name] ?? tool.name}… - - ))} - - ) - } - - if (tail) { - return ( - - 💭 {tail} - - ) - } + const tail = reasoning.slice(-160).replace(/\n/g, ' ') return ( - - {face} {verb}… - + <> + {tools.map(tool => ( + + {TOOL_VERBS[tool.name] ?? tool.name} + {tool.context ? ` ${tool.context}` : ''} + + ))} + + {!tools.length && ( + + {face} {verb}… + + )} + + {tail && 💭 {tail}} + ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 2211a483eb..64666014e3 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -29,6 +29,10 @@ export const HOTKEYS: [string, string][] = [ ['↑/↓', 'queue edit (if queued) / input history'], ['PgUp/PgDn', 'scroll messages'], ['Esc', 'clear input'], + ['Ctrl+A/E', 'home / end of line'], + ['Ctrl+W', 'delete word'], + ['Ctrl+←/→', 'jump word'], + ['Home/End', 'start / end of line'], ['\\+Enter', 'multi-line continuation'], ['!cmd', 'run shell command'], ['{!cmd}', 'interpolate shell output inline'], @@ -53,7 +57,7 @@ export const PLACEHOLDERS = [ export const ROLE: Record { body: string; glyph: string; prefix: string }> = { assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }), - system: t => ({ body: t.color.error, glyph: '!', prefix: t.color.error }), + system: t => ({ body: '', glyph: '·', prefix: t.color.dim }), tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) } diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 6300cef3a7..9af62a73f7 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -4,29 +4,31 @@ import { join } from 'node:path' const MAX = 1000 const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes')) -const file = join(dir, 'tui_history') +const file = join(dir, '.hermes_history') let cache: string[] | null = null -function encode(s: string): string { - return s.replace(/\\/g, '\\\\').replace(/\n/g, '\\n') -} - -function decode(s: string): string { - return s.replace(/\\n/g, '\n').replace(/\\\\/g, '\\') -} - export function load(): string[] { - if (cache) { - return cache - } + if (cache) return cache try { - if (existsSync(file)) { - cache = readFileSync(file, 'utf8').split('\n').filter(Boolean).map(decode).slice(-MAX) - } else { - cache = [] + if (!existsSync(file)) { cache = []; return cache } + + const lines = readFileSync(file, 'utf8').split('\n') + const entries: string[] = [] + let current: string[] = [] + + for (const line of lines) { + if (line.startsWith('+')) { + current.push(line.slice(1)) + } else if (current.length) { + entries.push(current.join('\n')) + current = [] + } } + if (current.length) entries.push(current.join('\n')) + + cache = entries.slice(-MAX) } catch { cache = [] } @@ -36,32 +38,21 @@ export function load(): string[] { export function append(line: string): void { const trimmed = line.trim() - - if (!trimmed) { - return - } + if (!trimmed) return const items = load() - - if (items.at(-1) === trimmed) { - return - } + if (items.at(-1) === trimmed) return items.push(trimmed) - - if (items.length > MAX) { - items.splice(0, items.length - MAX) - } + if (items.length > MAX) items.splice(0, items.length - MAX) try { - if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }) - } + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) - appendFileSync(file, encode(trimmed) + '\n') - } catch { - /* ignore */ - } + const ts = new Date().toISOString().replace('T', ' ').replace('Z', '') + const encoded = trimmed.split('\n').map(l => '+' + l).join('\n') + appendFileSync(file, `\n# ${ts}\n${encoded}\n`) + } catch { /* ignore */ } } export function all(): string[] { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 4e3bfce2d9..8a34f3cb75 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -1,6 +1,7 @@ export interface ActiveTool { id: string name: string + context?: string } export interface ApprovalReq { @@ -17,14 +18,22 @@ export interface ClarifyReq { export interface Msg { role: Role text: string + kind?: 'intro' | 'tool-active' + info?: SessionInfo + toolId?: string } export type Role = 'assistant' | 'system' | 'tool' | 'user' export interface SessionInfo { + cwd?: string model: string + release_date?: string skills: Record tools: Record + update_behind?: number | null + update_command?: string + version?: string } export interface Usage { From d9d0ac06b9201b164955d35ba3eeaba2c2a692be Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 7 Apr 2026 20:24:46 -0500 Subject: [PATCH 018/157] chore: readme update --- tui_gateway/server.py | 21 +- ui-tui/README.md | 337 ++++++++++++++++++-------- ui-tui/src/app.tsx | 13 +- ui-tui/src/components/markdown.tsx | 30 +-- ui-tui/src/components/messageLine.tsx | 6 +- ui-tui/src/components/thinking.tsx | 2 +- ui-tui/src/constants.ts | 12 +- 7 files changed, 281 insertions(+), 140 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 0b4be63ded..195192a5c4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1087,7 +1087,7 @@ def _(rid, params: dict) -> dict: # ── Methods: slash.exec ────────────────────────────────────────────── -def _mirror_slash_side_effects(session: dict, command: str): +def _mirror_slash_side_effects(sid: str, session: dict, command: str): """Apply side effects that must also hit the gateway's live agent.""" parts = command.lstrip("/").split(None, 1) if not parts: @@ -1097,7 +1097,22 @@ def _mirror_slash_side_effects(session: dict, command: str): try: if name == "model" and arg and agent: from hermes_cli.model_switch import switch_model - switch_model(agent, arg) + result = switch_model( + raw_input=arg, + current_provider=getattr(agent, "provider", "") or "", + current_model=getattr(agent, "model", "") or "", + current_base_url=getattr(agent, "base_url", "") or "", + current_api_key=getattr(agent, "api_key", "") or "", + ) + if result.success: + agent.switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + _emit("session.info", sid, _session_info(agent)) elif name == "compress" and agent: (getattr(agent, "compress_context", None) or getattr(agent, "context_compressor", agent).compress)() elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): @@ -1129,7 +1144,7 @@ def _(rid, params: dict) -> dict: try: output = worker.run(cmd) - _mirror_slash_side_effects(session, cmd) + _mirror_slash_side_effects(params.get("session_id", ""), session, cmd) return _ok(rid, {"output": output or "(no output)"}) except Exception as e: try: diff --git a/ui-tui/README.md b/ui-tui/README.md index 4b1e109c03..68ec5a43c7 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -1,89 +1,225 @@ # Hermes TUI -Ink-based terminal UI for the Hermes agent. Two processes, one stdio pipe: TypeScript renders the screen, Python runs the brain. +React + Ink terminal UI for Hermes. TypeScript owns the screen. Python owns sessions, tools, model calls, and most command logic. -``` +```bash hermes --tui ``` -## How it works +## What runs -The TUI spawns `tui_gateway.entry` as a child process. They talk newline-delimited JSON-RPC over stdin/stdout. Python handles sessions, tools, and LLM calls. Ink handles layout, input, and rendering. Stderr is piped into a ring buffer (never hits the terminal). +The client entrypoint is `src/entry.tsx`. It exits early if `stdin` is not a TTY, starts `GatewayClient`, then renders `App`. -``` -Ink (ui-tui/) Python (tui_gateway/) -───────────── ───────────────────── - TextInput entry.py - │ │ - ├─ JSON-RPC request ──────────► handle_request() - │ │ - │ server.py - │ 40 RPC methods - │ agent threads - │ │ - ◄── JSON-RPC response ─────────┤ - ◄── event push (streaming) ────┘ +`GatewayClient` spawns: + +```text +python -m tui_gateway.entry ``` -All Python writes go through a locked `write_json()` so concurrent agent threads can't interleave bytes on stdout. +By default it uses `venv/bin/python` from the repo root. Set `HERMES_PYTHON` to override. -## Layout +The transport is newline-delimited JSON-RPC over stdio: -The app runs in the alternate screen buffer. Everything fits in one fixed frame -- no native scroll. The message area gets whatever rows are left after subtracting chrome: +```text +ui-tui/src tui_gateway/ +----------- ------------- +entry.tsx entry.py + -> GatewayClient -> request loop + -> App -> server.py RPC handlers -``` -rows - header - thinking - queue - palette - statusbar - separator - input +stdin/stdout: JSON-RPC requests, responses, events +stderr: captured into an in-memory log ring ``` -The viewport walks backward from the newest message, estimating each one's rendered height, until the row budget runs out. PgUp/PgDn shift the window. +Malformed stdout lines are treated as protocol noise and surfaced as `gateway.protocol_error`. Stderr lines become `gateway.stderr`. Neither writes directly into the terminal. -## Hotkeys +## Running it -| Key | What it does | -|-----|-------------| -| Ctrl+C | Interrupt / clear / exit (contextual) | -| Ctrl+D | Exit | -| Ctrl+G | Open `$EDITOR` for multiline prompt | -| Ctrl+L | Clear messages | -| Ctrl+V | Paste clipboard image | -| Tab | Complete `/commands` | -| Up/Down | Cycle queue or input history | -| PgUp/PgDn | Scroll | -| Esc | Clear input | -| `\` + Enter | Continue on next line | -| `!cmd` | Shell command | -| `{!cmd}` | Interpolate shell output inline | +From the repo root, the normal path is: -## Ctrl+G editor +```bash +hermes --tui +``` -Writes the current input to a temp file, leaves the alt screen, opens your `$EDITOR`, then reads the file back and submits it on save. Multiline `\`-continued input pre-populates the file. +The CLI expects `ui-tui/node_modules` to exist. If the TUI deps are missing: -## Message queue +```bash +cd ui-tui +npm install +``` -Input typed while the agent is busy gets queued. The queue drains automatically after each response. Double-Enter sends the next queued item. Arrow keys let you edit queued messages before they send. +Local package commands: + +```bash +npm run dev +npm start +npm run build +npm run lint +npm run fmt +npm run fix +``` + +There is no package-local test script today. + +## App model + +`src/app.tsx` is the center of the UI. It holds: + +- transcript and streaming state +- queued messages and input history +- session lifecycle +- tool progress and reasoning text +- prompt flows for approval, clarify, sudo, and secret input +- slash command routing +- tab completion and path completion +- theme state from gateway skin data + +The UI renders as a normal Ink tree with `Static` transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list. + +The intro panel is driven by `session.info` and rendered through `branding.tsx`. + +## Hotkeys and interactions + +Current input behavior is split across `app.tsx`, `components/textInput.tsx`, and the prompt/picker components. + +### Main chat input + +| Key | Behavior | +|---|---| +| `Enter` | Submit the current draft | +| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message | +| `\` + `Enter` | Append the line to the multiline buffer instead of sending | +| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending | +| `Ctrl+D` | Exit | +| `Ctrl+G` | Open `$EDITOR` with the current draft | +| `Ctrl+L` | New session (same as `/clear`) | +| `Ctrl+V` | Paste clipboard image (same as `/paste`) | +| `Esc` | Clear the current draft | +| `Tab` | Apply the active completion | +| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history | +| `Left/Right` | Move the cursor | +| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key | +| `Home` / `Ctrl+A` | Start of line | +| `End` / `Ctrl+E` | End of line | +| `Backspace` / `Delete` | Delete the character to the left of the cursor | +| modified `Backspace` / `Delete` | Delete the previous word | +| `Ctrl+W` | Delete the previous word | +| `Ctrl+U` | Delete from the cursor back to the start of the line | +| `Ctrl+K` | Delete from the cursor to the end of the line | +| `Meta+B` / `Meta+F` | Move by word | +| `!cmd` | Run a shell command through the gateway | +| `{!cmd}` | Inline shell interpolation before send or queue | + +Notes: + +- `Tab` only applies completions when completions are present and you are not in multiline mode. +- Queue/history navigation only applies when you are not in multiline mode. +- `PgUp` / `PgDn` are left to the terminal emulator; the TUI does not handle them. + +### Prompt and picker modes + +| Context | Keys | Behavior | +|---|---|---| +| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice | +| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` | +| approval prompt | `Esc`, `Ctrl+C` | Deny | +| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice | +| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice | +| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry | +| clarify free-text mode | `Enter` | Submit typed answer | +| sudo / secret prompt | `Enter` | Submit typed value | +| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response | +| resume picker | `Up/Down`, `Enter` | Move and resume the selected session | +| resume picker | `1-9` | Quick-pick one of the first nine visible sessions | +| resume picker | `Esc`, `Ctrl+C` | Close the picker | + +Notes: + +- Clarify free-text mode and masked prompts use `ink-text-input`, so text editing there follows the library's default bindings rather than `components/textInput.tsx`. +- When a blocking prompt is open, the main chat input hotkeys are suspended. +- Clarify mode has no dedicated cancel shortcut in the current client. Sudo and secret prompts only expose `Ctrl+C` cancellation from the app-level blocked handler. + +### Interaction rules + +- Plain text entered while the agent is busy is queued instead of sent immediately. +- Slash commands and `!cmd` do not queue; they execute immediately even while a run is active. +- Queue auto-drains after each assistant response, unless a queued item is currently being edited. +- `Up/Down` prioritizes queued-message editing over history. History only activates when there is no queue to edit. +- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and the current run is interrupted first. +- Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`. +- Large pasted blocks, defined here as 5+ lines or more than 500 characters, are written under `~/.hermes/pastes` or `HERMES_HOME/pastes` and replaced with a placeholder. Smaller multiline pastes are flattened into spaces. +- `Ctrl+G` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly. +- Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`. ## Rendering -Assistant text goes through `markdown.tsx` -- a zero-dependency JSX renderer that handles code blocks (with diff coloring), headings, lists, quotes, tables, and inline formatting. If the Python side provides pre-rendered ANSI (via `agent.rich_output`), that takes priority. +Assistant output is rendered in one of two ways: -## Slash commands +- if the payload already contains ANSI, `messageLine.tsx` prints it directly +- otherwise `components/markdown.tsx` renders a small Markdown subset into Ink components -60+ commands wired in the local `slash()` switch. Anything unrecognized falls through to `command.dispatch` on the Python side (quick commands, plugins, skill commands) with alias resolution. `/help` lists them all. +The Markdown renderer handles headings, lists, block quotes, tables, fenced code blocks, diff coloring, inline code, emphasis, links, and plain URLs. -## Events (Python -> Ink) +Tool activity is shown separately through `components/thinking.tsx` while runs are active, then collapsed into tool completion rows in the transcript. + +## Prompt flows + +The Python gateway can pause the main loop and request structured input: + +- `approval.request`: allow once, allow for session, allow always, or deny +- `clarify.request`: pick from choices or type a custom answer +- `sudo.request`: masked password entry +- `secret.request`: masked value entry for a named env var +- `session.list`: used by `SessionPicker` for `/resume` + +These are stateful UI branches in `app.tsx`, not separate screens. + +## Commands + +The local slash handler covers the built-ins that need direct client behavior: + +- `/help` +- `/quit`, `/exit`, `/q` +- `/clear` +- `/new` +- `/compact` +- `/resume` +- `/copy` +- `/paste` +- `/logs` +- `/statusbar`, `/sb` +- `/queue` +- `/undo` +- `/retry` + +Notes: + +- `/copy` sends the selected assistant response through OSC 52. +- `/paste` asks the gateway for clipboard image attachment state. +- `/statusbar` currently toggles client state, but the status rule is still always rendered. + +Anything else falls through to: + +1. `slash.exec` +2. `command.dispatch` + +That lets Python own aliases, plugins, skills, and registry-backed commands without duplicating the logic in the TUI. + +## Event surface + +Primary event types the client handles today: | Event | Payload | -|-------|---------| +|---|---| | `gateway.ready` | `{ skin? }` | -| `session.info` | `{ model, tools, skills }` | -| `message.start` | -- | +| `session.info` | session metadata for banner + tool/skill panels | +| `message.start` | start assistant streaming | | `message.delta` | `{ text, rendered? }` | | `message.complete` | `{ text, rendered?, usage, status }` | | `thinking.delta` | `{ text }` | | `reasoning.delta` | `{ text }` | | `status.update` | `{ kind, text }` | -| `tool.generating` | `{ name }` | -| `tool.start` | `{ tool_id, name }` | +| `tool.start` | `{ tool_id, name, context? }` | | `tool.progress` | `{ name, preview }` | | `tool.complete` | `{ tool_id, name }` | | `clarify.request` | `{ question, choices?, request_id }` | @@ -93,67 +229,66 @@ Assistant text goes through `markdown.tsx` -- a zero-dependency JSX renderer tha | `background.complete` | `{ task_id, text }` | | `btw.complete` | `{ text }` | | `error` | `{ message }` | +| `gateway.stderr` | synthesized from child stderr | +| `gateway.protocol_error` | synthesized from malformed stdout | -The client also synthesizes `gateway.stderr` and `gateway.protocol_error` from the child process. +## Theme model -## RPC methods (40) +The client starts with `DEFAULT_THEME` from `theme.ts`, then merges in gateway skin data from `gateway.ready`. -Session: `session.create`, `session.list`, `session.resume`, `session.branch`, `session.title`, `session.usage`, `session.history`, `session.undo`, `session.compress`, `session.save`, `session.interrupt`, `terminal.resize` +Current branding overrides: -Prompts: `prompt.submit`, `prompt.background`, `prompt.btw` +- agent name +- prompt symbol +- welcome text +- goodbye text -Responses: `clarify.respond`, `approval.respond`, `sudo.respond`, `secret.respond`, `clipboard.paste` +Current color overrides: -Config: `config.set`, `config.get` +- banner title, accent, border, body, dim +- label, ok, error, warn -System: `process.stop`, `reload.mcp`, `shell.exec`, `cli.exec`, `commands.catalog`, `command.resolve`, `command.dispatch` +`branding.tsx` uses those values for the logo, session panel, and update notice. -Features: `voice.toggle`, `voice.record`, `voice.tts`, `insights.get`, `rollback.list`, `rollback.restore`, `rollback.diff`, `browser.manage`, `plugins.list`, `cron.manage`, `skills.manage` +## File map -## Performance notes - -- `MessageLine` and `Thinking` are `React.memo`'d so they skip re-render when the user types -- The spinner uses `useRef` instead of `useState` -- no parent re-renders at 80ms intervals -- `rpc` and `newSession` are `useCallback`-stable so the gateway event listener doesn't re-subscribe every render -- On a normal keystroke, only the input line re-renders. Viewport recalc only triggers on message/scroll changes. - -## Themes - -Python loads a skin from `~/.hermes/config.yaml` at startup. The `gateway.ready` event carries colors and branding to the client, which merges them into the default palette (gold/amber/bronze/cornsilk). Branding overrides the agent name, prompt symbol, and welcome text. - -## Files - -``` +```text ui-tui/src/ - entry.tsx entrypoint - app.tsx state, events, input, commands, layout - altScreen.tsx alternate screen buffer - gatewayClient.ts JSON-RPC child process bridge - constants.ts hotkeys, tool verbs, spinner frames - theme.ts palette + skin mapping - types.ts shared interfaces - banner.ts ASCII art - - lib/ - text.ts ANSI strip, row estimation - messages.ts streaming message upsert - history.ts persistent input history - slash.ts command palette + tab completion - osc52.ts clipboard via OSC 52 + entry.tsx TTY gate + render() + app.tsx main state machine and UI + gatewayClient.ts child process + JSON-RPC bridge + theme.ts default palette + skin merge + constants.ts display constants, hotkeys, tool labels + types.ts shared client-side types + banner.ts ASCII art data components/ - messageLine.tsx chat message (memoized) - markdown.tsx MD renderer (code, diff, tables) - thinking.tsx spinner / reasoning / tool progress - queuedMessages.tsx queue display - prompts.tsx approval + clarify - maskedPrompt.tsx sudo / secret input - sessionPicker.tsx session resume picker - commandPalette.tsx slash suggestions - branding.tsx welcome banner + session panel + branding.tsx banner + session summary + markdown.tsx Markdown-to-Ink renderer + maskedPrompt.tsx masked input for sudo / secrets + messageLine.tsx transcript rows + prompts.tsx approval + clarify flows + queuedMessages.tsx queued input preview + sessionPicker.tsx session resume picker + textInput.tsx custom line editor + thinking.tsx spinner, reasoning, tool activity -tui_gateway/ - entry.py stdin loop, JSON-RPC dispatch - server.py 40 RPC methods, session management - render.py optional Rich ANSI rendering bridge + lib/ + history.ts persistent input history + osc52.ts OSC 52 clipboard copy + text.ts text helpers, ANSI detection, previews ``` + +Related Python side: + +```text +tui_gateway/ + entry.py stdio entrypoint + server.py RPC handlers and session logic + render.py optional rich/ANSI bridge +``` + +## Notes + +- `src/main.tsx` currently duplicates `entry.tsx`. +- `src/altScreen.tsx`, `components/commandPalette.tsx`, and `lib/slash.ts` exist, but are not part of the active runtime path from `entry.tsx` to `app.tsx`. diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 02b3f69210..f2af728bb9 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -479,6 +479,16 @@ export function App({ gw }: { gw: GatewayClient }) { die() } + if (key.ctrl && ch === 'l') { + setStatus('forging session…') + newSession() + return + } + + if (key.ctrl && ch === 'v') { + paste() + return + } if (key.ctrl && ch === 'g') { return openEditor() @@ -586,7 +596,6 @@ export function App({ gw }: { gw: GatewayClient }) { case 'tool.start': { const ctx = (p.context as string) || '' setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: ctx }]) - appendMessage({ role: 'tool', text: `${TOOL_VERBS[p.name] ?? p.name}${ctx ? ' ' + ctx : ''}` }) break } @@ -595,7 +604,7 @@ export function App({ gw }: { gw: GatewayClient }) { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = done?.context || '' - appendMessage({ role: 'tool', text: `${label}${ctx ? ' ' + ctx : ''} ✓` }) + appendMessage({ role: 'tool', text: `${label}${ctx ? ': ' + ctx : ''} ✓` }) return prev.filter(t => t.id !== p.tool_id) }) break diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 26810a8dd6..65868c89d3 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -13,11 +13,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { const i = m.index ?? 0 if (i > last) { - parts.push( - - {text.slice(last, i)} - - ) + parts.push({text.slice(last, i)}) } if (m[2] && m[3]) { @@ -27,11 +23,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[4]) { - parts.push( - - {m[4]} - - ) + parts.push({m[4]}) } else if (m[5]) { parts.push( @@ -39,11 +31,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[6]) { - parts.push( - - {m[6]} - - ) + parts.push({m[6]}) } else if (m[7]) { parts.push( @@ -56,14 +44,10 @@ function MdInline({ t, text }: { t: Theme; text: string }) { } if (last < text.length) { - parts.push( - - {text.slice(last)} - - ) + parts.push({text.slice(last)}) } - return {parts.length ? parts : {text}} + return {parts.length ? parts : {text}} } export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) { @@ -121,7 +105,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st {block.map((l, j) => ( {tableRows.map((row, ri) => ( - + {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} ))} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 6e59f10609..e3058eb073 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -13,9 +13,9 @@ export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: if (msg.role === 'tool') { return ( - - {' '}{msg.text} - + + {msg.text} + ) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index e5f13992d1..4d24e812ad 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -37,7 +37,7 @@ export const Thinking = memo(function Thinking({ {tools.map(tool => ( {TOOL_VERBS[tool.name] ?? tool.name} - {tool.context ? ` ${tool.context}` : ''} + {tool.context ? `: ${tool.context}` : ''} ))} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 64666014e3..d1abdb78f9 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -23,21 +23,19 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+C', 'interrupt / clear / exit'], ['Ctrl+D', 'exit'], ['Ctrl+G', 'open $EDITOR for prompt'], - ['Ctrl+L', 'clear screen'], - ['Ctrl+V', 'paste clipboard image (same as /paste)'], - ['Tab', 'complete /commands (registry-aware)'], - ['↑/↓', 'queue edit (if queued) / input history'], - ['PgUp/PgDn', 'scroll messages'], + ['Ctrl+L', 'new session (clear)'], + ['Ctrl+V', 'paste clipboard image'], + ['Tab', 'apply completion'], + ['↑/↓', 'completions / queue edit / history'], ['Esc', 'clear input'], ['Ctrl+A/E', 'home / end of line'], ['Ctrl+W', 'delete word'], + ['Ctrl+U/K', 'delete to start / end'], ['Ctrl+←/→', 'jump word'], ['Home/End', 'start / end of line'], ['\\+Enter', 'multi-line continuation'], ['!cmd', 'run shell command'], ['{!cmd}', 'interpolate shell output inline'], - ['/voice record', 'start PTT recording'], - ['/voice stop', 'stop + transcribe'] ] export const INTERPOLATION_RE = /\{!(.+?)\}/g From c3eeb03e26ec9ff41b95a8928939e3603b6451bb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 7 Apr 2026 20:29:31 -0500 Subject: [PATCH 019/157] chore: clean exit --- hermes_cli/main.py | 19 +-- ui-tui/src/app.tsx | 294 +++++++++++++++++++++++++++++++++------------ 2 files changed, 228 insertions(+), 85 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c08f3f6462..8688b52d13 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -565,14 +565,19 @@ def _launch_tui(): tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): - sys.exit(subprocess.call([str(tsx), "src/entry.tsx"], cwd=str(tui_dir))) + argv = [str(tsx), "src/entry.tsx"] + else: + npm = shutil.which("npm") + if not npm: + print("npm not found in PATH. Source your nvm/node setup or set PATH.") + sys.exit(1) + argv = [npm, "start"] - npm = shutil.which("npm") - if not npm: - print("npm not found in PATH. Source your nvm/node setup or set PATH.") - sys.exit(1) - - sys.exit(subprocess.call([npm, "start"], cwd=str(tui_dir))) + try: + code = subprocess.call(argv, cwd=str(tui_dir)) + except KeyboardInterrupt: + code = 130 + sys.exit(code) def cmd_chat(args): diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index f2af728bb9..a950a14ab9 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -4,14 +4,15 @@ import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { Box, Static, Text, useApp, useInput, useStdout } from 'ink' -import { TextInput } from './components/textInput.js' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' + import { Banner, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' +import { TextInput } from './components/textInput.js' import { Thinking } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' @@ -41,20 +42,33 @@ const introMsg = (info: SessionInfo): Msg => ({ info }) -function extractTabWord(input: string): string | null { - const m = input.match(/((?:\.\.?\/|~\/|\/|@)[^\s]*)$/) - return m?.[1] ?? null -} +const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/ -function StatusRule({ cols, color, dimColor, statusColor, parts }: { - cols: number; color: string; dimColor: string; statusColor: string; parts: (string | false | undefined | null)[] +function StatusRule({ + cols, + color, + dimColor, + statusColor, + parts +}: { + cols: number + color: string + dimColor: string + statusColor: string + parts: (string | false | undefined | null)[] }) { const label = parts.filter(Boolean).join(' · ') + const lead = String(parts[0] ?? '') const fill = Math.max(0, cols - label.length - 5) return ( - {'─ '}{parts[0]}{label.slice(String(parts[0] || '').length)}{' ' + '─'.repeat(fill)} + {'─ '} + + {parts[0]} + {label.slice(lead.length)} + + {' ' + '─'.repeat(fill)} ) } @@ -65,10 +79,15 @@ export function App({ gw }: { gw: GatewayClient }) { const [cols, setCols] = useState(stdout?.columns ?? 80) useEffect(() => { - if (!stdout) return + if (!stdout) { + return + } const sync = () => setCols(stdout.columns ?? 80) stdout.on('resize', sync) - return () => { stdout.off('resize', sync) } + + return () => { + stdout.off('resize', sync) + } }, [stdout]) const [input, setInput] = useState('') @@ -108,7 +127,6 @@ export function App({ gw }: { gw: GatewayClient }) { const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) - const stderrWarnedRef = useRef(false) const pasteCounterRef = useRef(0) const empty = !messages.length @@ -167,31 +185,51 @@ export function App({ gw }: { gw: GatewayClient }) { const compInputRef = useRef('') useEffect(() => { - if (blocked) { if (completions.length) { setCompletions([]); setCompIdx(0) }; return } - if (input === compInputRef.current) return + if (blocked) { + if (completions.length) { + setCompletions([]) + setCompIdx(0) + } + + return + } + + if (input === compInputRef.current) { + return + } compInputRef.current = input const isSlash = input.startsWith('/') - const pathWord = !isSlash ? extractTabWord(input) : null + const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null if (!isSlash && !pathWord) { - if (completions.length) { setCompletions([]); setCompIdx(0) } + if (completions.length) { + setCompletions([]) + setCompIdx(0) + } + return } const t = setTimeout(() => { - if (compInputRef.current !== input) return + if (compInputRef.current !== input) { + return + } const req = isSlash ? gw.request('complete.slash', { text: input }) : gw.request('complete.path', { word: pathWord }) - req.then((r: any) => { - if (compInputRef.current !== input) return - setCompletions(r?.items ?? []) - setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) - }).catch(() => {}) + req + .then((r: any) => { + if (compInputRef.current !== input) { + return + } + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + }) + .catch(() => {}) }, 60) return () => clearTimeout(t) @@ -232,7 +270,6 @@ export function App({ gw }: { gw: GatewayClient }) { setStatus('ready') lastStatusNoteRef.current = '' protocolWarnedRef.current = false - stderrWarnedRef.current = false if (r.info) { setInfo(r.info) @@ -277,7 +314,11 @@ export function App({ gw }: { gw: GatewayClient }) { const expandPastes = (text: string) => text.replace(PASTE_REF_RE, (m, path) => { - try { return readFileSync(path, 'utf8') } catch { return m } + try { + return readFileSync(path, 'utf8') + } catch { + return m + } }) const collapsePaste = (text: string) => { @@ -285,8 +326,14 @@ export function App({ gw }: { gw: GatewayClient }) { const lineCount = text.split('\n').length const pasteDir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'pastes') mkdirSync(pasteDir, { recursive: true }) - const pasteFile = join(pasteDir, `paste_${pasteCounterRef.current}_${new Date().toTimeString().slice(0, 8).replace(/:/g, '')}.txt`) + + const pasteFile = join( + pasteDir, + `paste_${pasteCounterRef.current}_${new Date().toTimeString().slice(0, 8).replace(/:/g, '')}.txt` + ) + writeFileSync(pasteFile, text, 'utf8') + return `[Pasted text #${pasteCounterRef.current}: ${lineCount} lines → ${pasteFile}]` } @@ -310,9 +357,12 @@ export function App({ gw }: { gw: GatewayClient }) { gw.request('shell.exec', { command: cmd }) .then((r: any) => { const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() - sys(out || `exit ${r.code}`) - if (r.code !== 0 && out) { + if (out) { + sys(out) + } + + if (r.code !== 0 || !out) { sys(`exit ${r.code}`) } }) @@ -349,8 +399,8 @@ export function App({ gw }: { gw: GatewayClient }) { try { unlinkSync(file) - } catch (_) { - /* cleanup best-effort */ + } catch { + /* noop */ } } @@ -399,13 +449,18 @@ export function App({ gw }: { gw: GatewayClient }) { } if (completions.length && input && (key.upArrow || key.downArrow)) { - setCompIdx(i => key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length) + setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)) + return } if (!inputBuf.length && key.tab && completions.length) { - const pick = completions[compIdx] - if (pick) setInput(input.slice(0, compReplace) + pick.text) + const row = completions[compIdx] + + if (row) { + setInput(input.slice(0, compReplace) + row.text) + } + return } @@ -459,9 +514,11 @@ export function App({ gw }: { gw: GatewayClient }) { if (busy && sid) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + if (buf.current.trim()) { appendMessage({ role: 'assistant' as const, text: buf.current.trimStart() }) } + idle() setStatus('interrupted') sys('interrupted by user') @@ -482,11 +539,13 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.ctrl && ch === 'l') { setStatus('forging session…') newSession() + return } if (key.ctrl && ch === 'v') { paste() + return } @@ -530,6 +589,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'session.info': setInfo(p as SessionInfo) + break case 'thinking.delta': @@ -559,9 +619,6 @@ export function App({ gw }: { gw: GatewayClient }) { break - case 'gateway.stderr': - break - case 'gateway.protocol_error': setStatus('protocol warning') @@ -579,23 +636,24 @@ export function App({ gw }: { gw: GatewayClient }) { break - case 'tool.generating': - break - case 'tool.progress': if (p?.preview) { setTools(prev => { const idx = prev.findIndex(t => t.name === p.name) - if (idx >= 0) return [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] + + if (idx >= 0) { + return [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] + } + return prev }) } break - case 'tool.start': { const ctx = (p.context as string) || '' setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: ctx }]) + break } @@ -605,8 +663,10 @@ export function App({ gw }: { gw: GatewayClient }) { const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = done?.context || '' appendMessage({ role: 'tool', text: `${label}${ctx ? ': ' + ctx : ''} ✓` }) + return prev.filter(t => t.id !== p.tool_id) }) + break case 'clarify.request': @@ -644,7 +704,9 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.delta': - if (!p?.text || interruptedRef.current) break + if (!p?.text || interruptedRef.current) { + break + } buf.current += p.rendered ?? p.text setStreaming(buf.current.trimStart()) @@ -732,108 +794,164 @@ export function App({ gw }: { gw: GatewayClient }) { .filter(Boolean) .join('\n') ) + return true } case 'quit': + case 'exit': + case 'q': die() + return true case 'clear': setStatus('forging session…') newSession() + return true case 'new': setStatus('forging session…') newSession('new session started') + return true case 'compact': setCompact(c => (arg ? true : !c)) sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) + return true case 'resume': - if (!sid) { setPicker(true); return true } setPicker(true) - return true + return true case 'copy': { const all = messages.filter(m => m.role === 'assistant') const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.length - 1] - if (!target) { sys('nothing to copy'); return true } + + if (!target) { + sys('nothing to copy') + + return true + } + writeOsc52Clipboard(target.text) sys('copied to clipboard') + return true } case 'paste': paste() - return true + return true case 'logs': { const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20)) sys(gw.getLogTail(limit) || 'no gateway logs') + return true } case 'statusbar': + case 'sb': setStatusBar(v => !v) sys(`status bar ${statusBar ? 'off' : 'on'}`) + return true case 'queue': - if (!arg) { sys(`${queueRef.current.length} queued message(s)`); return true } + if (!arg) { + sys(`${queueRef.current.length} queued message(s)`) + + return true + } + enqueue(arg) sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + return true case 'undo': - if (!sid) return true + if (!sid) { + return true + } rpc('session.undo', { session_id: sid }).then((r: any) => { if (r.removed > 0) { setMessages(prev => { const q = [...prev] - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() - if (q.at(-1)?.role === 'user') q.pop() + + 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') + } else { + sys('nothing to undo') + } }) + return true case 'retry': - if (!lastUserMsg) { sys('nothing to retry'); return true } - if (sid) gw.request('session.undo', { session_id: sid }).catch(() => {}) + if (!lastUserMsg) { + sys('nothing to retry') + + return true + } + + if (sid) { + gw.request('session.undo', { session_id: sid }).catch(() => {}) + } setMessages(prev => { const q = [...prev] - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') q.pop() + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + return q }) send(lastUserMsg) + return true default: rpc('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => { - if (r?.output) sys(r.output) - else sys(`/${name}: no output`) + if (r?.output) { + sys(r.output) + } else { + sys(`/${name}: no output`) + } }) .catch(() => { gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) .then((d: any) => { - if (d.type === 'exec') sys(d.output || '(no output)') - else if (d.type === 'alias') slash(`/${d.target}${arg ? ' ' + arg : ''}`) - else if (d.type === 'plugin') sys(d.output || '(no output)') - else if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`); send(d.message) } + if (d.type === 'exec') { + sys(d.output || '(no output)') + } else if (d.type === 'alias') { + slash(`/${d.target}${arg ? ' ' + arg : ''}`) + } else if (d.type === 'plugin') { + sys(d.output || '(no output)') + } else if (d.type === 'skill') { + sys(`⚡ loading skill: ${d.name}`) + send(d.message) + } }) .catch(() => sys(`unknown command: /${name}`)) }) + return true } }, @@ -899,11 +1017,13 @@ export function App({ gw }: { gw: GatewayClient }) { syncQueue() gw.request('session.interrupt', { session_id: sid }).catch(() => {}) setStatus('interrupting…') + return } if (picked && sid) { send(picked) + return } @@ -965,16 +1085,14 @@ export function App({ gw }: { gw: GatewayClient }) { {(m, i) => ( - {m.kind === 'intro' && m.info - ? ( - - - - - ) - : ( - - )} + {m.kind === 'intro' && m.info ? ( + + + + + ) : ( + + )} )} @@ -1052,11 +1170,13 @@ export function App({ gw }: { gw: GatewayClient }) { setSid(r.session_id) setMessages([]) setInfo(r.info ?? null) - if (r.info) appendHistory(introMsg(r.info)) + + if (r.info) { + appendHistory(introMsg(r.info)) + } setUsage(ZERO) lastStatusNoteRef.current = '' protocolWarnedRef.current = false - stderrWarnedRef.current = false sys(`resumed session (${r.message_count} messages)`) setStatus('ready') }) @@ -1071,23 +1191,38 @@ export function App({ gw }: { gw: GatewayClient }) { - {' '} + - 0 && `${fmtK(usage.total)} tok`]} /> + 0 && `${fmtK(usage.total)} tok`]} + statusColor={statusColor} + /> {!blocked && ( - {inputBuf.length ? '… ' : `${theme.brand.prompt} `} + + {inputBuf.length ? '… ' : `${theme.brand.prompt} `} + )} @@ -1096,9 +1231,12 @@ export function App({ gw }: { gw: GatewayClient }) { {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { const active = Math.max(0, compIdx - 8) + i === compIdx + return ( - {item.display} + + {item.display} + {item.meta ? {item.meta} : null} ) From 9c2c9e3a3ec639c5cfaa405c9a2c3e4f075fcc3b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 7 Apr 2026 20:30:22 -0500 Subject: [PATCH 020/157] chore: fmt --- ui-tui/src/app.tsx | 6 + ui-tui/src/components/branding.tsx | 28 ++++- ui-tui/src/components/markdown.tsx | 19 +++- ui-tui/src/components/messageLine.tsx | 30 +++-- ui-tui/src/components/textInput.tsx | 156 ++++++++++++++++++++------ ui-tui/src/components/thinking.tsx | 21 +++- ui-tui/src/constants.ts | 2 +- ui-tui/src/lib/history.ts | 43 +++++-- 8 files changed, 236 insertions(+), 69 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index a950a14ab9..0109be3f44 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -82,6 +82,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!stdout) { return } + const sync = () => setCols(stdout.columns ?? 80) stdout.on('resize', sync) @@ -197,6 +198,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (input === compInputRef.current) { return } + compInputRef.current = input const isSlash = input.startsWith('/') @@ -225,6 +227,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (compInputRef.current !== input) { return } + setCompletions(r?.items ?? []) setCompIdx(0) setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) @@ -880,6 +883,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!sid) { return true } + rpc('session.undo', { session_id: sid }).then((r: any) => { if (r.removed > 0) { setMessages(prev => { @@ -913,6 +917,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (sid) { gw.request('session.undo', { session_id: sid }).catch(() => {}) } + setMessages(prev => { const q = [...prev] @@ -1174,6 +1179,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (r.info) { appendHistory(introMsg(r.info)) } + setUsage(ZERO) lastStatusNoteRef.current = '' protocolWarnedRef.current = false diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 45214accb0..e18b5523b3 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -75,7 +75,11 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {truncLine(strip(k) + ': ', vs)} ))} - {overflow > 0 && (and {overflow} {overflowLabel})} + {overflow > 0 && ( + + (and {overflow} {overflowLabel}) + + )} ) } @@ -90,13 +94,17 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {info.model.split('/').pop()} · Nous Research - {cwd} + + {cwd} + {sid && Session: {sid}} )} - {title} + + {title} + {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} @@ -110,9 +118,17 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {typeof info.update_behind === 'number' && info.update_behind > 0 && ( ⚠ {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind - — run - {info.update_command || 'hermes update'} - to update + + {' '} + — run{' '} + + + {info.update_command || 'hermes update'} + + + {' '} + to update + )} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 65868c89d3..2abb5bf41b 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -23,7 +23,11 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[4]) { - parts.push({m[4]}) + parts.push( + + {m[4]} + + ) } else if (m[5]) { parts.push( @@ -31,7 +35,11 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[6]) { - parts.push({m[6]}) + parts.push( + + {m[6]} + + ) } else if (m[7]) { parts.push( @@ -58,7 +66,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const gap = () => { if (nodes.length && prevKind !== 'blank') { - nodes.push({' '}) + nodes.push( ) prevKind = 'blank' } } @@ -67,6 +75,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st if (prevKind && prevKind !== 'blank' && prevKind !== kind) { gap() } + prevKind = kind } @@ -104,9 +113,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => ( diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index e3058eb073..1274a51f29 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -5,9 +5,20 @@ import { LONG_MSG, ROLE } from '../constants.js' import { hasAnsi, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg } from '../types.js' + import { Md } from './markdown.js' -export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: { cols: number; compact?: boolean; msg: Msg; t: Theme }) { +export const MessageLine = memo(function MessageLine({ + cols, + compact, + msg, + t +}: { + cols: number + compact?: boolean + msg: Msg + t: Theme +}) { const { body, glyph, prefix } = ROLE[msg.role](t) const contentWidth = Math.max(20, cols - 5) @@ -20,8 +31,9 @@ export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: } const content = (() => { - if (msg.role === 'assistant') + if (msg.role === 'assistant') { return hasAnsi(msg.text) ? {msg.text} : + } if (msg.role === 'user' && msg.text.length > LONG_MSG) { const [head, ...rest] = userDisplay(msg.text).split('[long message]') @@ -29,7 +41,9 @@ export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: return ( {head} - [long message] + + [long message] + {rest.join('')} ) @@ -40,16 +54,16 @@ export const MessageLine = memo(function MessageLine({ cols, compact, msg, t }: return ( - {(msg.role === 'user' || msg.role === 'assistant') && {' '}} + {(msg.role === 'user' || msg.role === 'assistant') && } - {glyph} + + {glyph}{' '} + - - {content} - + {content} ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 63a19750fe..2ac64efb37 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -3,15 +3,29 @@ import { useEffect, useRef, useState } from 'react' function wl(s: string, p: number) { let i = p - 1 - while (i > 0 && /\s/.test(s[i]!)) i-- - while (i > 0 && !/\s/.test(s[i - 1]!)) i-- + + while (i > 0 && /\s/.test(s[i]!)) { + i-- + } + + while (i > 0 && !/\s/.test(s[i - 1]!)) { + i-- + } + return Math.max(0, i) } function wr(s: string, p: number) { let i = p - while (i < s.length && !/\s/.test(s[i]!)) i++ - while (i < s.length && /\s/.test(s[i]!)) i++ + + while (i < s.length && !/\s/.test(s[i]!)) { + i++ + } + + while (i < s.length && /\s/.test(s[i]!)) { + i++ + } + return i } @@ -21,7 +35,7 @@ const INV_OFF = ESC + '[27m' const DIM = ESC + '[2m' const DIM_OFF = ESC + '[22m' const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ -const BRACKET_PASTE = /\x1b\[20[01]~/g +const BRACKET_PASTE = new RegExp(`${ESC}\\[20[01]~`, 'g') interface Props { value: string @@ -42,7 +56,11 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder vRef.current = value useEffect(() => { - if (selfChange.current) { selfChange.current = false } else { setCur(value.length) } + if (selfChange.current) { + selfChange.current = false + } else { + setCur(value.length) + } }, [value]) const flushPaste = () => { @@ -50,9 +68,13 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder const at = pastePos.current pasteBuf.current = '' pasteTimer.current = null - if (!pasted) return + + if (!pasted) { + return + } const v = vRef.current + if (pasted.split('\n').length >= 5 || pasted.length > 500) { const ph = onLargePaste?.(pasted) ?? pasted.replace(/\n/g, ' ') const nv = v.slice(0, at) + ph + v.slice(at) @@ -61,6 +83,7 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder setCur(at + ph.length) } else { const clean = pasted.replace(/\n/g, ' ') + if (clean.length && PRINTABLE.test(clean)) { const nv = v.slice(0, at) + clean + v.slice(at) selfChange.current = true @@ -72,57 +95,120 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder useInput( (inp, k) => { - if (k.upArrow || k.downArrow || (k.ctrl && inp === 'c') || k.tab || (k.shift && k.tab) || k.pageUp || k.pageDown || k.escape) + if ( + k.upArrow || + k.downArrow || + (k.ctrl && inp === 'c') || + k.tab || + (k.shift && k.tab) || + k.pageUp || + k.pageDown || + k.escape + ) { return - if (k.return) { onSubmit?.(value); return } + } - let c = cur, v = value + if (k.return) { + onSubmit?.(value) + + return + } + + let c = cur, + v = value const mod = k.ctrl || k.meta - if (k.home || (k.ctrl && inp === 'a')) c = 0 - else if (k.end || (k.ctrl && inp === 'e')) c = v.length - else if (k.leftArrow) c = mod ? wl(v, c) : Math.max(0, c - 1) - else if (k.rightArrow) c = mod ? wr(v, c) : Math.min(v.length, c + 1) - else if ((k.backspace || k.delete) && c > 0) { - if (mod) { const t = wl(v, c); v = v.slice(0, t) + v.slice(c); c = t } - else { v = v.slice(0, c - 1) + v.slice(c); c-- } - } - else if (k.ctrl && inp === 'w' && c > 0) { const t = wl(v, c); v = v.slice(0, t) + v.slice(c); c = t } - else if (k.ctrl && inp === 'u') { v = v.slice(c); c = 0 } - else if (k.ctrl && inp === 'k') v = v.slice(0, c) - else if (k.meta && inp === 'b') c = wl(v, c) - else if (k.meta && inp === 'f') c = wr(v, c) - else if (inp.length > 0) { + if (k.home || (k.ctrl && inp === 'a')) { + c = 0 + } else if (k.end || (k.ctrl && inp === 'e')) { + c = v.length + } else if (k.leftArrow) { + c = mod ? wl(v, c) : Math.max(0, c - 1) + } else if (k.rightArrow) { + c = mod ? wr(v, c) : Math.min(v.length, c + 1) + } else if ((k.backspace || k.delete) && c > 0) { + if (mod) { + const t = wl(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else { + v = v.slice(0, c - 1) + v.slice(c) + c-- + } + } else if (k.ctrl && inp === 'w' && c > 0) { + const t = wl(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else if (k.ctrl && inp === 'u') { + v = v.slice(c) + c = 0 + } else if (k.ctrl && inp === 'k') { + v = v.slice(0, c) + } else if (k.meta && inp === 'b') { + c = wl(v, c) + } else if (k.meta && inp === 'f') { + c = wr(v, c) + } else if (inp.length > 0) { const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') - if (!raw) return + + if (!raw) { + return + } const isMultiChar = raw.length > 1 || raw.includes('\n') if (isMultiChar) { - if (!pasteBuf.current) pastePos.current = c + if (!pasteBuf.current) { + pastePos.current = c + } pasteBuf.current += raw - if (pasteTimer.current) clearTimeout(pasteTimer.current) + + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } pasteTimer.current = setTimeout(flushPaste, 50) + return } - if (PRINTABLE.test(raw)) { v = v.slice(0, c) + raw + v.slice(c); c += raw.length } - else return + if (PRINTABLE.test(raw)) { + v = v.slice(0, c) + raw + v.slice(c) + c += raw.length + } else { + return + } + } else { + return } - else return c = Math.max(0, Math.min(c, v.length)) setCur(c) - if (v !== value) { selfChange.current = true; onChange(v) } + + if (v !== value) { + selfChange.current = true + onChange(v) + } }, { isActive: focus } ) - if (!focus) return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} - if (!value && placeholder) return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} + if (!focus) { + return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} + } + + if (!value && placeholder) { + return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} + } let r = '' - for (let i = 0; i < value.length; i++) r += i === cur ? INV + value[i] + INV_OFF : value[i] - if (cur === value.length) r += INV + ' ' + INV_OFF + + for (let i = 0; i < value.length; i++) { + r += i === cur ? INV + value[i] + INV_OFF : value[i] + } + + if (cur === value.length) { + r += INV + ' ' + INV_OFF + } + return {r} } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 4d24e812ad..183401f8f4 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -11,6 +11,7 @@ function Spinner({ color }: { color: string }) { useEffect(() => { const id = setInterval(() => setI(p => (p + 1) % SPINNER.length), 80) + return () => clearInterval(id) }, []) @@ -18,15 +19,23 @@ function Spinner({ color }: { color: string }) { } export const Thinking = memo(function Thinking({ - reasoning, t, tools + reasoning, + t, + tools }: { - reasoning: string; t: Theme; tools: ActiveTool[] + reasoning: string + t: Theme + tools: ActiveTool[] }) { const [verb, setVerb] = useState(() => pick(VERBS)) const [face, setFace] = useState(() => pick(FACES)) useEffect(() => { - const id = setInterval(() => { setVerb(pick(VERBS)); setFace(pick(FACES)) }, 1100) + const id = setInterval(() => { + setVerb(pick(VERBS)) + setFace(pick(FACES)) + }, 1100) + return () => clearInterval(id) }, []) @@ -47,7 +56,11 @@ export const Thinking = memo(function Thinking({ )} - {tail && 💭 {tail}} + {tail && ( + + 💭 {tail} + + )} ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index d1abdb78f9..83660c4cb3 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -35,7 +35,7 @@ export const HOTKEYS: [string, string][] = [ ['Home/End', 'start / end of line'], ['\\+Enter', 'multi-line continuation'], ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'], + ['{!cmd}', 'interpolate shell output inline'] ] export const INTERPOLATION_RE = /\{!(.+?)\}/g diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 9af62a73f7..7beb1516ac 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -9,10 +9,16 @@ const file = join(dir, '.hermes_history') let cache: string[] | null = null export function load(): string[] { - if (cache) return cache + if (cache) { + return cache + } try { - if (!existsSync(file)) { cache = []; return cache } + if (!existsSync(file)) { + cache = [] + + return cache + } const lines = readFileSync(file, 'utf8').split('\n') const entries: string[] = [] @@ -26,7 +32,10 @@ export function load(): string[] { current = [] } } - if (current.length) entries.push(current.join('\n')) + + if (current.length) { + entries.push(current.join('\n')) + } cache = entries.slice(-MAX) } catch { @@ -38,21 +47,37 @@ export function load(): string[] { export function append(line: string): void { const trimmed = line.trim() - if (!trimmed) return + + if (!trimmed) { + return + } const items = load() - if (items.at(-1) === trimmed) return + + if (items.at(-1) === trimmed) { + return + } items.push(trimmed) - if (items.length > MAX) items.splice(0, items.length - MAX) + + if (items.length > MAX) { + items.splice(0, items.length - MAX) + } try { - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }) + } const ts = new Date().toISOString().replace('T', ' ').replace('Z', '') - const encoded = trimmed.split('\n').map(l => '+' + l).join('\n') + const encoded = trimmed + .split('\n') + .map(l => '+' + l) + .join('\n') appendFileSync(file, `\n# ${ts}\n${encoded}\n`) - } catch { /* ignore */ } + } catch { + /* ignore */ + } } export function all(): string[] { From b397c91d4ad840a4211d455f1a4b182259f3bb75 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 7 Apr 2026 20:44:18 -0500 Subject: [PATCH 021/157] chore: uptick --- ui-tui/src/altScreen.tsx | 29 ------ ui-tui/src/app.tsx | 110 ++------------------ ui-tui/src/components/commandPalette.tsx | 22 ---- ui-tui/src/components/textInput.tsx | 3 + ui-tui/src/hooks/useCompletion.ts | 67 ++++++++++++ ui-tui/src/hooks/useInputHistory.ts | 20 ++++ ui-tui/src/hooks/useQueue.ts | 35 +++++++ ui-tui/src/lib/history.ts | 2 + ui-tui/src/lib/slash.ts | 124 ----------------------- ui-tui/src/main.tsx | 14 --- 10 files changed, 136 insertions(+), 290 deletions(-) delete mode 100644 ui-tui/src/altScreen.tsx delete mode 100644 ui-tui/src/components/commandPalette.tsx create mode 100644 ui-tui/src/hooks/useCompletion.ts create mode 100644 ui-tui/src/hooks/useInputHistory.ts create mode 100644 ui-tui/src/hooks/useQueue.ts delete mode 100644 ui-tui/src/lib/slash.ts delete mode 100644 ui-tui/src/main.tsx diff --git a/ui-tui/src/altScreen.tsx b/ui-tui/src/altScreen.tsx deleted file mode 100644 index 34f88a6a99..0000000000 --- a/ui-tui/src/altScreen.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Box, useStdout } from 'ink' -import { type PropsWithChildren, useEffect } from 'react' - -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/app.tsx b/ui-tui/src/app.tsx index 0109be3f44..dac76ff8ef 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -16,7 +16,9 @@ import { TextInput } from './components/textInput.js' import { Thinking } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' -import * as inputHistory from './lib/history.js' +import { useCompletion } from './hooks/useCompletion.js' +import { useInputHistory } from './hooks/useInputHistory.js' +import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' import { fmtK, hasInterpolation, pick } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' @@ -42,8 +44,6 @@ const introMsg = (info: SessionInfo): Msg => ({ info }) -const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/ - function StatusRule({ cols, color, @@ -113,60 +113,24 @@ export function App({ gw }: { gw: GatewayClient }) { const [thinkingText, setThinkingText] = useState('') const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') - const [queueEditIdx, setQueueEditIdx] = useState(null) - const [historyIdx, setHistoryIdx] = useState(null) const [streaming, setStreaming] = useState('') - const [queuedDisplay, setQueuedDisplay] = useState([]) const [catalog, setCatalog] = useState(null) const buf = useRef('') const interruptedRef = useRef(false) - const queueRef = useRef([]) - const historyRef = useRef(inputHistory.load()) - const historyDraftRef = useRef('') - const queueEditRef = useRef(null) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const pasteCounterRef = useRef(0) + const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = + useQueue() + + const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() + const empty = !messages.length const blocked = !!(clarify || approval || sudo || secret || picker) - const syncQueue = () => setQueuedDisplay([...queueRef.current]) - - const setQueueEdit = (idx: number | null) => { - queueEditRef.current = idx - setQueueEditIdx(idx) - } - - const enqueue = (text: string) => { - queueRef.current.push(text) - syncQueue() - } - - const dequeue = () => { - const [head, ...rest] = queueRef.current - queueRef.current = rest - syncQueue() - - return head - } - - const replaceQ = (i: number, text: string) => { - queueRef.current[i] = text - syncQueue() - } - - const pushHistory = (text: string) => { - const trimmed = text.trim() - - if (trimmed && historyRef.current.at(-1) !== trimmed) { - historyRef.current.push(trimmed) - inputHistory.append(trimmed) - } - } - useEffect(() => { if (!sid || !stdout) { return @@ -180,63 +144,7 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps - const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) - const [compIdx, setCompIdx] = useState(0) - const [compReplace, setCompReplace] = useState(0) - const compInputRef = useRef('') - - useEffect(() => { - if (blocked) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) - } - - return - } - - if (input === compInputRef.current) { - return - } - - compInputRef.current = input - - const isSlash = input.startsWith('/') - const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null - - if (!isSlash && !pathWord) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) - } - - return - } - - const t = setTimeout(() => { - if (compInputRef.current !== input) { - return - } - - const req = isSlash - ? gw.request('complete.slash', { text: input }) - : gw.request('complete.path', { word: pathWord }) - - req - .then((r: any) => { - if (compInputRef.current !== input) { - return - } - - setCompletions(r?.items ?? []) - setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) - }) - .catch(() => {}) - }, 60) - - return () => clearTimeout(t) - }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps + const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked, gw) const appendMessage = useCallback((msg: Msg) => { setMessages(prev => [...prev, msg]) diff --git a/ui-tui/src/components/commandPalette.tsx b/ui-tui/src/components/commandPalette.tsx deleted file mode 100644 index 2dad8c04d2..0000000000 --- a/ui-tui/src/components/commandPalette.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Box, Text } from 'ink' - -import type { Theme } from '../theme.js' - -export function CommandPalette({ matches, t }: { matches: [string, string][]; t: Theme }) { - if (!matches.length) { - return null - } - - return ( - - {matches.map(([cmd, desc], i) => ( - - - {cmd} - - {desc ? — {desc} : null} - - ))} - - ) -} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 2ac64efb37..389fd27c3e 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -116,6 +116,7 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder let c = cur, v = value + const mod = k.ctrl || k.meta if (k.home || (k.ctrl && inp === 'a')) { @@ -161,11 +162,13 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder if (!pasteBuf.current) { pastePos.current = c } + pasteBuf.current += raw if (pasteTimer.current) { clearTimeout(pasteTimer.current) } + pasteTimer.current = setTimeout(flushPaste, 50) return diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts new file mode 100644 index 0000000000..a700127115 --- /dev/null +++ b/ui-tui/src/hooks/useCompletion.ts @@ -0,0 +1,67 @@ +import { useEffect, useRef, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' + +const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/ + +export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { + const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) + const [compIdx, setCompIdx] = useState(0) + const [compReplace, setCompReplace] = useState(0) + const ref = useRef('') + + useEffect(() => { + if (blocked) { + if (completions.length) { + setCompletions([]) + setCompIdx(0) + } + + return + } + + if (input === ref.current) { + return + } + + ref.current = input + + const isSlash = input.startsWith('/') + const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null + + if (!isSlash && !pathWord) { + if (completions.length) { + setCompletions([]) + setCompIdx(0) + } + + return + } + + const t = setTimeout(() => { + if (ref.current !== input) { + return + } + + const req = isSlash + ? gw.request('complete.slash', { text: input }) + : gw.request('complete.path', { word: pathWord }) + + req + .then((r: any) => { + if (ref.current !== input) { + return + } + + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + }) + .catch(() => {}) + }, 60) + + return () => clearTimeout(t) + }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps + + return { completions, compIdx, setCompIdx, compReplace } +} diff --git a/ui-tui/src/hooks/useInputHistory.ts b/ui-tui/src/hooks/useInputHistory.ts new file mode 100644 index 0000000000..a7b7d2ecae --- /dev/null +++ b/ui-tui/src/hooks/useInputHistory.ts @@ -0,0 +1,20 @@ +import { useRef, useState } from 'react' + +import * as inputHistory from '../lib/history.js' + +export function useInputHistory() { + const historyRef = useRef(inputHistory.load()) + const [historyIdx, setHistoryIdx] = useState(null) + const historyDraftRef = useRef('') + + const pushHistory = (text: string) => { + const trimmed = text.trim() + + if (trimmed && historyRef.current.at(-1) !== trimmed) { + historyRef.current.push(trimmed) + inputHistory.append(trimmed) + } + } + + return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } +} diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts new file mode 100644 index 0000000000..c0df224ff0 --- /dev/null +++ b/ui-tui/src/hooks/useQueue.ts @@ -0,0 +1,35 @@ +import { useRef, useState } from 'react' + +export function useQueue() { + const queueRef = useRef([]) + const [queuedDisplay, setQueuedDisplay] = useState([]) + const queueEditRef = useRef(null) + const [queueEditIdx, setQueueEditIdx] = useState(null) + + const syncQueue = () => setQueuedDisplay([...queueRef.current]) + + const setQueueEdit = (idx: number | null) => { + queueEditRef.current = idx + setQueueEditIdx(idx) + } + + const enqueue = (text: string) => { + queueRef.current.push(text) + syncQueue() + } + + const dequeue = () => { + const [head, ...rest] = queueRef.current + queueRef.current = rest + syncQueue() + + return head + } + + const replaceQ = (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() + } + + return { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } +} diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 7beb1516ac..50125d3b56 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -70,10 +70,12 @@ export function append(line: string): void { } const ts = new Date().toISOString().replace('T', ' ').replace('Z', '') + const encoded = trimmed .split('\n') .map(l => '+' + l) .join('\n') + appendFileSync(file, `\n# ${ts}\n${encoded}\n`) } catch { /* ignore */ diff --git a/ui-tui/src/lib/slash.ts b/ui-tui/src/lib/slash.ts deleted file mode 100644 index 07b106c7d1..0000000000 --- a/ui-tui/src/lib/slash.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { SlashCatalog } from '../types.js' - -/** Match SlashCommandCompleter: command names, subcommands, then skills. */ -export function paletteForLine(line: string, c: SlashCatalog | null): [string, string][] { - if (!c || !line.startsWith('/')) { - return [] - } - - const parts = line.split(/\s+/) - const baseRaw = parts[0]! - const base = baseRaw.toLowerCase() - const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) - - if (inSub) { - const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' - - if (subText.includes(' ') || parts.length > 2) { - return [] - } - - const head = subText.split(/\s+/)[0] ?? '' - - if (subText.includes(' ') && head !== subText) { - return [] - } - - const canonical = c.canon[base] ?? baseRaw - const subs = c.sub[canonical] - - if (!subs?.length) { - return [] - } - - const lo = head.toLowerCase() - - return subs - .filter(s => s.toLowerCase().startsWith(lo) && s.toLowerCase() !== lo) - .slice(0, 14) - .map(s => [s, '']) - } - - const word = line.slice(1) - - return c.pairs - .filter(([k]) => k.slice(1).startsWith(word)) - .slice(0, 16) - .map(([k, d]) => [k, d]) -} - -/** Tab: longest common prefix of palette matches, or first unique completion + space. */ -export function tabAdvance(line: string, c: SlashCatalog | null): string | null { - if (!c || !line.startsWith('/')) { - return null - } - - const rows = paletteForLine(line, c) - - if (!rows.length) { - return null - } - - const parts = line.split(/\s+/) - const baseRaw = parts[0]! - const base = baseRaw.toLowerCase() - const inSub = parts.length > 1 || (parts.length === 1 && line.endsWith(' ')) - - if (inSub) { - const subText = parts.length > 1 ? parts.slice(1).join(' ') : '' - const head = subText.split(/\s+/)[0] ?? '' - const picks = rows.map(([s]) => s) - - if (picks.length === 1) { - return `${baseRaw} ${picks[0]!} ` - } - - const cp = commonPrefix(picks) - - if (cp.length > head.length) { - return `${baseRaw} ${cp}` - } - - return null - } - - const word = line.slice(1) - const names = rows.map(([k]) => k.slice(1)) - const cp = commonPrefix(names) - - if (names.length === 1) { - return `/${names[0]!} ` - } - - if (cp.length > word.length) { - return `/${cp}` - } - - return null -} - -function commonPrefix(xs: string[]): string { - if (!xs.length) { - return '' - } - - let n = 0 - - outer: while (true) { - const ch = xs[0]![n] - - if (ch === undefined) { - break - } - - for (const x of xs) { - if (x[n] !== ch) { - break outer - } - } - - n++ - } - - return xs[0]!.slice(0, n) -} diff --git a/ui-tui/src/main.tsx b/ui-tui/src/main.tsx deleted file mode 100644 index b8c247d97a..0000000000 --- a/ui-tui/src/main.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { render } from 'ink' -import React from 'react' - -import { App } from './app.js' -import { GatewayClient } from './gatewayClient.js' - -if (!process.stdin.isTTY) { - console.log('hermes-tui: no TTY') - process.exit(0) -} - -const gw = new GatewayClient() -gw.start() -render(, { exitOnCtrlC: false }) From 2d884ff12deb622e25d764bc2ccab2b834199bf0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 7 Apr 2026 20:46:59 -0500 Subject: [PATCH 022/157] chore: uptick --- ui-tui/src/app.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index dac76ff8ef..858aadec23 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -568,15 +568,17 @@ export function App({ gw }: { gw: GatewayClient }) { break } - case 'tool.complete': + case 'tool.complete': { + const mark = p.error ? '✗' : '✓' setTools(prev => { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name - const ctx = done?.context || '' - appendMessage({ role: 'tool', text: `${label}${ctx ? ': ' + ctx : ''} ✓` }) + const ctx = (p.error as string) || done?.context || '' + appendMessage({ role: 'tool', text: `${label}${ctx ? ': ' + ctx : ''} ${mark}` }) return prev.filter(t => t.id !== p.tool_id) }) + } break From af077b2c0df62a13a72433bab234bd7a8610feb5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 7 Apr 2026 20:47:59 -0500 Subject: [PATCH 023/157] fix: history up arrow --- ui-tui/src/hooks/useInputHistory.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/ui-tui/src/hooks/useInputHistory.ts b/ui-tui/src/hooks/useInputHistory.ts index a7b7d2ecae..0793178fd6 100644 --- a/ui-tui/src/hooks/useInputHistory.ts +++ b/ui-tui/src/hooks/useInputHistory.ts @@ -7,14 +7,7 @@ export function useInputHistory() { const [historyIdx, setHistoryIdx] = useState(null) const historyDraftRef = useRef('') - const pushHistory = (text: string) => { - const trimmed = text.trim() - - if (trimmed && historyRef.current.at(-1) !== trimmed) { - historyRef.current.push(trimmed) - inputHistory.append(trimmed) - } - } + const pushHistory = (text: string) => inputHistory.append(text) return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } } From ebd2d83ef2dff210ebba3f9f6f6e7175251cffe3 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 7 Apr 2026 23:59:11 -0400 Subject: [PATCH 024/157] feat: add skin logo support --- tui_gateway/server.py | 8 +++- ui-tui/package-lock.json | 11 +++++ ui-tui/src/app.tsx | 2 +- ui-tui/src/banner.ts | 64 +++++++++++++++++++++++++++++- ui-tui/src/components/branding.tsx | 17 +++++--- ui-tui/src/theme.ts | 19 +++++++-- 6 files changed, 108 insertions(+), 13 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 195192a5c4..67f9f0c47b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -205,7 +205,13 @@ def resolve_skin() -> dict: from hermes_cli.skin_engine import init_skin_from_config, get_active_skin init_skin_from_config(_load_cfg()) skin = get_active_skin() - return {"name": skin.name, "colors": skin.colors, "branding": skin.branding} + return { + "name": skin.name, + "colors": skin.colors, + "branding": skin.branding, + "banner_logo": skin.banner_logo, + "banner_hero": skin.banner_hero, + } except Exception: return {} diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index e378fa2c64..85d196129c 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -73,6 +73,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1092,6 +1093,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1102,6 +1104,7 @@ "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", @@ -1131,6 +1134,7 @@ "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -1335,6 +1339,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1647,6 +1652,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2313,6 +2319,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4238,6 +4245,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4308,6 +4316,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5060,6 +5069,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5351,6 +5361,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 858aadec23..574f69f7bc 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -476,7 +476,7 @@ export function App({ gw }: { gw: GatewayClient }) { switch (ev.type) { case 'gateway.ready': if (p?.skin) { - setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {})) + setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {}, p.skin.banner_logo ?? '', p.skin.banner_hero ?? '')) } rpc('commands.catalog', {}) diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts index afc8a94dd0..6324cafe10 100644 --- a/ui-tui/src/banner.ts +++ b/ui-tui/src/banner.ts @@ -2,6 +2,50 @@ import type { ThemeColors } from './theme.js' type Line = [string, string] +// ── Rich markup parser ────────────────────────────────────────────── +// Parses Python Rich markup like "[bold #A3261F]text[/]" into Line[]. +const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g + +export function parseRichMarkup(markup: string): Line[] { + const lines: Line[] = [] + + for (const raw of markup.split('\n')) { + const trimmed = raw.trimEnd() + + if (!trimmed) { + lines.push(['', ' ']) + + continue + } + + let lastIndex = 0 + let matched = false + let m: RegExpExecArray | null + + RICH_RE.lastIndex = 0 + + while ((m = RICH_RE.exec(trimmed)) !== null) { + matched = true + const before = trimmed.slice(lastIndex, m.index) + + if (before) { + lines.push(['', before]) + } + + lines.push([m[1]!, m[2]!]) + lastIndex = m.index + m[0].length + } + + if (!matched) { + lines.push(['', trimmed]) + } else if (lastIndex < trimmed.length) { + lines.push(['', trimmed.slice(lastIndex)]) + } + } + + return lines +} + const LOGO_ART = [ '██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', '██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', @@ -39,6 +83,22 @@ function colorize(art: string[], gradient: readonly number[], c: ThemeColors): L } export const LOGO_WIDTH = 98 +export const CADUCEUS_WIDTH = 30 -export const logo = (c: ThemeColors) => colorize(LOGO_ART, LOGO_GRADIENT, c) -export const caduceus = (c: ThemeColors) => colorize(CADUCEUS_ART, CADUC_GRADIENT, c) +export const logo = (c: ThemeColors, customLogo?: string): Line[] => + customLogo ? parseRichMarkup(customLogo) : colorize(LOGO_ART, LOGO_GRADIENT, c) + +export const caduceus = (c: ThemeColors, customHero?: string): Line[] => + customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c) + +export function artWidth(lines: Line[]): number { + let max = 0 + + for (const [, text] of lines) { + if (text.length > max) { + max = text.length + } + } + + return max +} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index e18b5523b3..cd8f0b9d53 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -1,6 +1,6 @@ import { Box, Text, useStdout } from 'ink' -import { caduceus, logo, LOGO_WIDTH } from '../banner.js' +import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js' import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' import type { SessionInfo } from '../types.js' @@ -19,16 +19,19 @@ export function ArtLines({ lines }: { lines: [string, string][] }) { export function Banner({ t }: { t: Theme }) { const cols = useStdout().stdout?.columns ?? 80 + const logoLines = logo(t.color, t.bannerLogo || undefined) + const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH return ( - {cols >= LOGO_WIDTH ? ( - + {cols >= logoW ? ( + ) : ( {t.brand.icon} NOUS HERMES )} + {t.brand.icon} Nous Research · Messenger of the Digital Gods ) @@ -36,8 +39,10 @@ export function Banner({ t }: { t: Theme }) { export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) { const cols = useStdout().stdout?.columns ?? 100 - const wide = cols >= 90 - const leftW = wide ? 34 : 0 + const heroLines = caduceus(t.color, t.bannerHero || undefined) + const heroW = artWidth(heroLines) || CADUCEUS_WIDTH + const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4)) + const wide = cols >= 90 && leftW + 40 < cols const w = wide ? cols - leftW - 12 : cols - 10 const cwd = info.cwd || process.cwd() const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) @@ -88,7 +93,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {wide && ( - + {info.model.split('/').pop()} diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index caa194f38e..29db1480ff 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -30,6 +30,8 @@ export interface ThemeBrand { export interface Theme { color: ThemeColors brand: ThemeBrand + bannerLogo: string + bannerHero: string } export const DEFAULT_THEME: Theme = { @@ -60,10 +62,18 @@ export const DEFAULT_THEME: Theme = { welcome: 'Type your message or /help for commands.', goodbye: 'Goodbye! ⚕', tool: '┊' - } + }, + + bannerLogo: '', + bannerHero: '' } -export function fromSkin(colors: Record, branding: Record): Theme { +export function fromSkin( + colors: Record, + branding: Record, + bannerLogo = '', + bannerHero = '' +): Theme { const d = DEFAULT_THEME const c = (k: string) => colors[k] @@ -95,6 +105,9 @@ export function fromSkin(colors: Record, branding: Record Date: Wed, 8 Apr 2026 00:15:15 -0400 Subject: [PATCH 025/157] feat: personality --- tui_gateway/server.py | 11 ++++++++++- tui_gateway/slash_worker.py | 7 +++++++ ui-tui/src/app.tsx | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 67f9f0c47b..dd375b836e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -319,9 +319,13 @@ def _wire_callbacks(sid: str): def _make_agent(sid: str, key: str, session_id: str | None = None): from run_agent import AIAgent + cfg = _load_cfg() + system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" return AIAgent( model=_resolve_model(), quiet_mode=True, platform="tui", - session_id=session_id or key, session_db=_get_db(), **_agent_cbs(sid), + session_id=session_id or key, session_db=_get_db(), + ephemeral_system_prompt=system_prompt or None, + **_agent_cbs(sid), ) @@ -1119,6 +1123,11 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str): api_mode=result.api_mode, ) _emit("session.info", sid, _session_info(agent)) + elif name in ("personality", "prompt") and agent: + cfg = _load_cfg() + new_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" + agent.ephemeral_system_prompt = new_prompt or None + agent._cached_system_prompt = None elif name == "compress" and agent: (getattr(agent, "compress_context", None) or getattr(agent, "context_compressor", agent).compress)() elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): diff --git a/tui_gateway/slash_worker.py b/tui_gateway/slash_worker.py index 5d37418643..631b0c7045 100644 --- a/tui_gateway/slash_worker.py +++ b/tui_gateway/slash_worker.py @@ -12,6 +12,7 @@ import sys import cli as cli_mod from cli import HermesCLI +from rich.console import Console def _run(cli: HermesCLI, command: str) -> str: @@ -22,6 +23,12 @@ def _run(cli: HermesCLI, command: str) -> str: cmd = f"/{cmd}" buf = io.StringIO() + + # Rich Console captures its file handle at construction time, so + # contextlib.redirect_stdout won't affect it. Swap the console's + # underlying file to our buffer so self.console.print() is captured. + cli.console = Console(file=buf, force_terminal=True, width=120) + old = getattr(cli_mod, "_cprint", None) if old is not None: cli_mod._cprint = lambda text: print(text) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 574f69f7bc..5f31d83859 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -842,7 +842,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true default: - rpc('slash.exec', { command: cmd.slice(1), session_id: sid }) + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => { if (r?.output) { sys(r.output) From a3cfb1de8671c794c512eb8f7d111959d13cab80 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 09:46:40 -0500 Subject: [PATCH 026/157] feat: auto install tui deps --- hermes_cli/main.py | 15 ++++++++++++--- scripts/install.ps1 | 14 ++++++++++++++ scripts/install.sh | 10 ++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8688b52d13..12ddd15d6f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -559,9 +559,18 @@ def _launch_tui(): tui_dir = PROJECT_ROOT / "ui-tui" if not (tui_dir / "node_modules").exists(): - print("TUI dependencies not installed.") - print(f" cd {tui_dir} && npm install") - sys.exit(1) + npm = shutil.which("npm") + if not npm: + print("npm not found — install Node.js to use the TUI.") + sys.exit(1) + print("Installing TUI dependencies…") + result = subprocess.run( + [npm, "install", "--silent"], + cwd=str(tui_dir), capture_output=True, text=True, + ) + if result.returncode != 0: + print(f"npm install failed:\n{result.stderr}") + sys.exit(1) tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d644c6221f..80ed53cce8 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -721,6 +721,20 @@ function Install-NodeDeps { } } + # Install TUI dependencies + $tuiDir = "$InstallDir\ui-tui" + if (Test-Path "$tuiDir\package.json") { + Write-Info "Installing TUI dependencies..." + Push-Location $tuiDir + try { + npm install --silent 2>&1 | Out-Null + Write-Success "TUI dependencies installed" + } catch { + Write-Warn "TUI npm install failed (hermes --tui may not work)" + } + Pop-Location + } + # Install WhatsApp bridge dependencies $bridgeDir = "$InstallDir\scripts\whatsapp-bridge" if (Test-Path "$bridgeDir\package.json") { diff --git a/scripts/install.sh b/scripts/install.sh index c04dc4a9d5..b44f538fa1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -916,6 +916,16 @@ install_node_deps() { log_success "Browser engine installed" fi + # Install TUI dependencies + if [ -f "$INSTALL_DIR/ui-tui/package.json" ]; then + log_info "Installing TUI dependencies..." + cd "$INSTALL_DIR/ui-tui" + npm install --silent 2>/dev/null || { + log_warn "TUI npm install failed (hermes --tui may not work)" + } + log_success "TUI dependencies installed" + fi + # Install WhatsApp bridge dependencies if [ -f "$INSTALL_DIR/scripts/whatsapp-bridge/package.json" ]; then log_info "Installing WhatsApp bridge dependencies..." From a9fa054df9f740f48267dd667ae7cc072403db3d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 10:35:07 -0500 Subject: [PATCH 027/157] chore: uptick --- ui-tui/src/app.tsx | 166 ++++++++++++++++++++++++-- ui-tui/src/components/messageLine.tsx | 2 +- 2 files changed, 159 insertions(+), 9 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 5f31d83859..740e0ffcd7 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -426,13 +426,15 @@ export function App({ gw }: { gw: GatewayClient }) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - if (buf.current.trim()) { - appendMessage({ role: 'assistant' as const, text: buf.current.trimStart() }) + const partial = (streaming || buf.current).trimStart() + if (partial) { + appendMessage({ role: 'assistant' as const, text: partial + '\n\n*[interrupted]*' }) + } else { + sys('interrupted') } idle() setStatus('interrupted') - sys('interrupted by user') setTimeout(() => setStatus('ready'), 1500) } else if (input || inputBuf.length) { clearIn() @@ -626,9 +628,14 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.complete': { + const wasInterrupted = interruptedRef.current idle() setStreaming('') - appendMessage({ role: 'assistant' as const, text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) + + if (!wasInterrupted) { + appendMessage({ role: 'assistant' as const, text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) + } + buf.current = '' setStatus('ready') @@ -636,10 +643,6 @@ export function App({ gw }: { gw: GatewayClient }) { setUsage(p.usage) } - if (p?.status === 'interrupted') { - sys('response interrupted') - } - if (queueEditRef.current !== null) { break } @@ -841,6 +844,153 @@ export function App({ gw }: { gw: GatewayClient }) { return true + case 'background': + case 'bg': + if (!arg) { + sys('/background ') + return true + } + rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => sys(`bg ${r.task_id} started`)) + return true + + case 'btw': + if (!arg) { + sys('/btw ') + return true + } + rpc('prompt.btw', { session_id: sid, text: arg }).then(() => sys('btw running…')) + return true + + case 'model': + if (!arg) { + rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) + } else { + rpc('config.set', { key: 'model', value: arg.replace('--global', '').trim() }).then((r: any) => { + sys(`model → ${r.value}`) + setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) + }) + } + return true + + case 'yolo': + rpc('config.set', { key: 'yolo' }).then((r: any) => sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)) + return true + + case 'reasoning': + rpc('config.set', { key: 'reasoning', value: arg || 'medium' }).then((r: any) => sys(`reasoning: ${r.value}`)) + return true + + case 'verbose': + rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose: ${r.value}`)) + return true + + case 'personality': + rpc('config.set', { key: 'personality', value: arg }).then((r: any) => + sys(`personality: ${r.value || 'default'}`) + ) + return true + + case 'compress': + rpc('session.compress', { session_id: sid }).then((r: any) => + sys(`compressed${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) + ) + return true + + case 'stop': + rpc('process.stop', {}).then((r: any) => sys(`killed ${r.killed ?? 0} process(es)`)) + return true + + case 'branch': + case 'fork': + rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { + if (r?.session_id) { + setSid(r.session_id) + setMessages([]) + sys(`branched → ${r.title}`) + } + }) + return true + + case 'reload-mcp': + case 'reload_mcp': + rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP reloaded')) + return true + + case 'title': + rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => + sys(`title: ${r.title || '(none)'}`) + ) + return true + + case 'usage': + rpc('session.usage', { session_id: sid }).then((r: any) => { + if (r) setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) + sys(`${fmtK(r?.input ?? 0)} in · ${fmtK(r?.output ?? 0)} out · ${fmtK(r?.total ?? 0)} total · ${r?.calls ?? 0} calls`) + }) + return true + + case 'save': + rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved: ${r.file}`)) + return true + + case 'history': + rpc('session.history', { session_id: sid }).then((r: any) => sys(`${r.count} messages`)) + return true + + case 'profile': + rpc('config.get', { key: 'profile' }).then((r: any) => sys(r.display || r.home)) + return true + + case 'provider': + rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) + return true + + case 'voice': + if (arg === 'on' || arg === 'off') { + rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice ${r.enabled ? 'on' : 'off'}`)) + } else { + rpc('voice.toggle', { action: 'status' }).then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) + } + return true + + case 'insights': + rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => + sys(`${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) + ) + return true + + case 'rollback': { + const [sub, ...rArgs] = (arg || 'list').split(/\s+/) + if (!sub || sub === 'list') { + rpc('rollback.list', { session_id: sid }).then((r: any) => { + if (!r.checkpoints?.length) return sys('no checkpoints') + sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) + }) + } else { + const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub + rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { session_id: sid, hash }).then((r: any) => + sys(r.rendered || r.diff || r.message || 'done') + ) + } + return true + } + + case 'browser': { + const action = arg || 'status' + const [act, ...bArgs] = action.split(/\s+/) + rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => + sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') + ) + return true + } + + case 'plugins': + rpc('plugins.list', {}).then((r: any) => { + if (!r.plugins?.length) return sys('no plugins') + sys(r.plugins.map((p: any) => ` ${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`).join('\n')) + }) + return true + default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => { diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 1274a51f29..5e8b843540 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -24,7 +24,7 @@ export const MessageLine = memo(function MessageLine({ if (msg.role === 'tool') { return ( - + {msg.text} ) From b50d81f212120d38184af5c51f61d55bc21e2db7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 12:11:55 -0500 Subject: [PATCH 028/157] fix: diff colours --- ui-tui/src/app.tsx | 21 ++++++++++++++++----- ui-tui/src/components/markdown.tsx | 25 ++++++++++++++++--------- ui-tui/src/theme.ts | 19 +++++++++++++++++-- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 740e0ffcd7..e18adba99a 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1031,14 +1031,25 @@ export function App({ gw }: { gw: GatewayClient }) { const dbl = now - lastEmptyAt.current < 450 lastEmptyAt.current = now - if (dbl && queueRef.current.length) { - if (busy && sid) { - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - setStatus('interrupting…') + if (dbl && busy && sid) { + interruptedRef.current = true + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - return + const partial = (streaming || buf.current).trimStart() + if (partial) { + appendMessage({ role: 'assistant' as const, text: partial + '\n\n*[interrupted]*' }) + } else { + sys('interrupted') } + idle() + setStatus('interrupted') + setTimeout(() => setStatus('ready'), 1500) + + return + } + + if (dbl && queueRef.current.length) { const next = dequeue() if (next && sid) { diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 2abb5bf41b..9a75952f54 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -111,15 +111,22 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st nodes.push( {lang && !isDiff && {'─ ' + lang}} - {block.map((l, j) => ( - - {l} - - ))} + {block.map((l, j) => { + const add = isDiff && l.startsWith('+') + const del = isDiff && l.startsWith('-') + const hunk = isDiff && l.startsWith('@@') + + return ( + + {l} + + ) + })} ) diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 29db1480ff..37f1d5bde7 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -16,6 +16,11 @@ export interface ThemeColors { statusWarn: string statusBad: string statusCritical: string + + diffAdded: string + diffRemoved: string + diffAddedWord: string + diffRemovedWord: string } export interface ThemeBrand { @@ -52,7 +57,12 @@ export const DEFAULT_THEME: Theme = { statusGood: '#8FBC8F', statusWarn: '#FFD700', statusBad: '#FF8C00', - statusCritical: '#FF6B6B' + statusCritical: '#FF6B6B', + + diffAdded: 'rgb(220,255,220)', + diffRemoved: 'rgb(255,220,220)', + diffAddedWord: 'rgb(36,138,61)', + diffRemovedWord: 'rgb(207,34,46)', }, brand: { @@ -95,7 +105,12 @@ export function fromSkin( statusGood: c('ui_ok') ?? d.color.statusGood, statusWarn: c('ui_warn') ?? d.color.statusWarn, statusBad: d.color.statusBad, - statusCritical: d.color.statusCritical + statusCritical: d.color.statusCritical, + + diffAdded: d.color.diffAdded, + diffRemoved: d.color.diffRemoved, + diffAddedWord: d.color.diffAddedWord, + diffRemovedWord: d.color.diffRemovedWord, }, brand: { From af0f4a52fe2a4a806df33a2c36c1bbebe65134e4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 13:45:34 -0500 Subject: [PATCH 029/157] feat: cute spinners --- ui-tui/README.md | 12 +- ui-tui/bun.lock | 756 +++++++++++++++++++++++++ ui-tui/package.json | 3 +- ui-tui/src/app.tsx | 743 +++++++++++++++++------- ui-tui/src/components/activityLane.tsx | 26 + ui-tui/src/components/messageLine.tsx | 4 +- ui-tui/src/components/pasteShelf.tsx | 47 ++ ui-tui/src/components/textInput.tsx | 29 +- ui-tui/src/components/thinking.tsx | 28 +- ui-tui/src/constants.ts | 2 - ui-tui/src/types.ts | 19 + 11 files changed, 1429 insertions(+), 240 deletions(-) create mode 100644 ui-tui/bun.lock create mode 100644 ui-tui/src/components/activityLane.tsx create mode 100644 ui-tui/src/components/pasteShelf.tsx diff --git a/ui-tui/README.md b/ui-tui/README.md index 68ec5a43c7..89719a43b9 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -147,7 +147,10 @@ Notes: - `Up/Down` prioritizes queued-message editing over history. History only activates when there is no queue to edit. - If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and the current run is interrupted first. - Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`. -- Large pasted blocks, defined here as 5+ lines or more than 500 characters, are written under `~/.hermes/pastes` or `HERMES_HOME/pastes` and replaced with a placeholder. Smaller multiline pastes are flattened into spaces. +- Text pastes are captured into a local paste shelf and inserted as `[[paste:]]` tokens. Nothing is newline-flattened. +- Small pastes default to `excerpt` mode. Larger pastes default to `attach` mode. +- Very large paste references trigger a confirmation prompt before send. +- Pasted content is scanned for obvious secret patterns before send and redacted in the outbound payload. - `Ctrl+G` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly. - Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`. @@ -160,7 +163,7 @@ Assistant output is rendered in one of two ways: The Markdown renderer handles headings, lists, block quotes, tables, fenced code blocks, diff coloring, inline code, emphasis, links, and plain URLs. -Tool activity is shown separately through `components/thinking.tsx` while runs are active, then collapsed into tool completion rows in the transcript. +Tool/status activity is shown in a live activity lane. Transcript rows stay focused on user/assistant turns. ## Prompt flows @@ -195,8 +198,9 @@ The local slash handler covers the built-ins that need direct client behavior: Notes: - `/copy` sends the selected assistant response through OSC 52. -- `/paste` asks the gateway for clipboard image attachment state. -- `/statusbar` currently toggles client state, but the status rule is still always rendered. +- `/paste` with no args asks the gateway for clipboard image attachment state. +- `/paste list|mode|drop|clear` manages text paste-shelf items. +- `/statusbar` toggles the status rule on/off. Anything else falls through to: diff --git a/ui-tui/bun.lock b/ui-tui/bun.lock new file mode 100644 index 0000000000..c93554b990 --- /dev/null +++ b/ui-tui/bun.lock @@ -0,0 +1,756 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "hermes-tui", + "dependencies": { + "ink": "^6.8.0", + "ink-text-input": "^6.0.0", + "react": "^19.2.4", + "unicode-animations": "^1.0.3", + }, + "devDependencies": { + "@eslint/js": "^9", + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@typescript-eslint/eslint-plugin": "^8", + "@typescript-eslint/parser": "^8", + "eslint": "^9", + "eslint-plugin-perfectionist": "^5", + "eslint-plugin-react": "^7", + "eslint-plugin-react-hooks": "^7", + "eslint-plugin-unused-imports": "^4", + "globals": "^16", + "prettier": "^3", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + }, + }, + }, + "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + + "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.5", "", { "os": "android", "cpu": "arm" }, "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.5", "", { "os": "android", "cpu": "arm64" }, "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.5", "", { "os": "android", "cpu": "x64" }, "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.5", "", { "os": "linux", "cpu": "arm" }, "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.5", "", { "os": "none", "cpu": "x64" }, "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.5", "", { "os": "win32", "cpu": "x64" }, "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], + + "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/type-utils": "8.58.0", "@typescript-eslint/utils": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.0", "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0" } }, "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], + + "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], + + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], + + "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], + + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], + + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], + + "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], + + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.13", "", { "bin": "dist/cli.cjs" }, "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw=="], + + "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001784", "", {}, "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], + + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], + + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], + + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], + + "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], + + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], + + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], + + "esbuild": ["esbuild@0.27.5", "", { "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" }, "bin": "bin/esbuild" }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": "bin/eslint.js" }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], + + "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@5.8.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw=="], + + "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" } }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], + + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], + + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], + + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], + + "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], + + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + + "ink": ["ink@6.8.0", "", { "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" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], + + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], + + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], + + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], + + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], + + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], + + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": "cli.js" }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], + + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], + + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], + + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], + + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], + + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], + + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], + + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], + + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], + + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], + + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "natural-orderby": ["natural-orderby@5.0.0", "", {}, "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg=="], + + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], + + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], + + "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], + + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], + + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], + + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], + + "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], + + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], + + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], + + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], + + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], + + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], + + "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + + "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], + + "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], + + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], + + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], + + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], + + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], + + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], + + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], + + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "unicode-animations": ["unicode-animations@1.0.3", "", { "dependencies": { "unicode-animations": "^1.0.1" }, "bin": { "unicode-animations": "scripts/demo.cjs" } }, "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], + + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], + + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], + + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], + + "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "ink-text-input/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "node-exports-info/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + + "widest-line/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], + + "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + + "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + } +} diff --git a/ui-tui/package.json b/ui-tui/package.json index 2ea39f685b..ccf17b20b7 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -15,7 +15,8 @@ "dependencies": { "ink": "^6.8.0", "ink-text-input": "^6.0.0", - "react": "^19.2.4" + "react": "^19.2.4", + "unicode-animations": "^1.0.3" }, "devDependencies": { "@eslint/js": "^9", diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e18adba99a..31be2cb9f5 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,11 +1,12 @@ import { spawnSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { homedir, tmpdir } from 'node:os' +import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { Box, Static, Text, useApp, useInput, useStdout } from 'ink' import { useCallback, useEffect, useRef, useState } from 'react' +import { ActivityLane } from './components/activityLane.js' import { Banner, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' @@ -20,13 +21,16 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { fmtK, hasInterpolation, pick } from './lib/text.js' +import { compactPreview, fmtK, hasInterpolation, pick } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, + ActivityItem, ApprovalReq, ClarifyReq, Msg, + PasteMode, + PendingPaste, SecretReq, SessionInfo, SlashCatalog, @@ -35,7 +39,23 @@ import type { } from './types.js' const PLACEHOLDER = pick(PLACEHOLDERS) -const PASTE_REF_RE = /\[Pasted text #\d+: \d+ lines \u2192 (.+?)\]/g +const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g +const EXCERPT_CHAR_LIMIT = 1200 +const EXCERPT_LINE_LIMIT = 14 +const LARGE_PASTE_CHAR_LIMIT = 8000 +const LARGE_PASTE_LINE_LIMIT = 80 +const SMALL_PASTE_CHAR_LIMIT = 400 +const SMALL_PASTE_LINE_LIMIT = 4 + +const SECRET_PATTERNS = [ + /AKIA[0-9A-Z]{16}/g, + /AIza[0-9A-Za-z-_]{30,}/g, + /gh[pousr]_[A-Za-z0-9]{20,}/g, + /sk-[A-Za-z0-9]{20,}/g, + /sk-ant-[A-Za-z0-9-]{20,}/g, + /xox[baprs]-[A-Za-z0-9-]{10,}/g, + /\b(?:api[_-]?key|token|secret)\b\s*[:=]\s*["']?[A-Za-z0-9_-]{12,}/gi +] const introMsg = (info: SessionInfo): Msg => ({ role: 'system', @@ -44,6 +64,46 @@ const introMsg = (info: SessionInfo): Msg => ({ info }) +const classifyPaste = (text: string): PendingPaste['kind'] => { + const t = text.toLowerCase() + const lines = text.split('\n') + + if (/error|warn|traceback|exception|stack|debug|\[\d{2}:\d{2}:\d{2}\]/.test(t)) { + return 'log' + } + + if ( + /```|function\s+\w+|class\s+\w+|import\s+.+from|const\s+\w+\s*=|def\s+\w+\(|<\w+/.test(text) || + lines.filter(line => /[{}()[\];<>]/.test(line)).length >= 3 + ) { + return 'code' + } + + return 'text' +} + +const redactSecrets = (text: string) => { + let output = text + let redactions = 0 + + for (const pattern of SECRET_PATTERNS) { + output = output.replace(pattern, value => { + redactions += 1 + + if (value.includes(':') || value.includes('=')) { + const [head] = value.split(/[:=]/) + return `${head}: [REDACTED_SECRET]` + } + + return '[REDACTED_SECRET]' + }) + } + + return { redactions, text: output } +} + +const tokenForPaste = (id: number) => `[[paste:${id}]]` + function StatusRule({ cols, color, @@ -99,7 +159,10 @@ export function App({ gw }: { gw: GatewayClient }) { const [sid, setSid] = useState(null) const [theme, setTheme] = useState(DEFAULT_THEME) const [info, setInfo] = useState(null) + const [introCollapsed, setIntroCollapsed] = useState(false) const [thinking, setThinking] = useState(false) + const [turnKey, setTurnKey] = useState(0) + const [activity, setActivity] = useState([]) const [tools, setTools] = useState([]) const [busy, setBusy] = useState(false) const [compact, setCompact] = useState(false) @@ -113,11 +176,16 @@ export function App({ gw }: { gw: GatewayClient }) { const [thinkingText, setThinkingText] = useState('') const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') + const [pastes, setPastes] = useState([]) + const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null) const [streaming, setStreaming] = useState('') const [catalog, setCatalog] = useState(null) + const activityIdRef = useRef(0) const buf = useRef('') + const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) + const slashRef = useRef<(cmd: string) => boolean>(() => false) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) @@ -129,7 +197,7 @@ export function App({ gw }: { gw: GatewayClient }) { const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const empty = !messages.length - const blocked = !!(clarify || approval || sudo || secret || picker) + const blocked = !!(clarify || approval || pasteReview || picker || secret || sudo) useEffect(() => { if (!sid || !stdout) { @@ -157,6 +225,17 @@ export function App({ gw }: { gw: GatewayClient }) { const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info') => { + setActivity(prev => { + if (prev.at(-1)?.text === text && prev.at(-1)?.tone === tone) { + return prev + } + + activityIdRef.current += 1 + return [...prev, { id: activityIdRef.current, text, tone }].slice(-8) + }) + }, []) + const colsRef = useRef(cols) colsRef.current = cols @@ -176,9 +255,13 @@ export function App({ gw }: { gw: GatewayClient }) { } setSid(r.session_id) + setHistoryItems([]) setMessages([]) + setPastes([]) + setActivity([]) setUsage(ZERO) setStatus('ready') + setIntroCollapsed(false) lastStatusNoteRef.current = '' protocolWarnedRef.current = false @@ -199,9 +282,11 @@ export function App({ gw }: { gw: GatewayClient }) { const idle = () => { setThinking(false) setTools([]) + setActivity([]) setBusy(false) setClarify(null) setApproval(null) + setPasteReview(null) setSudo(null) setSecret(null) setReasoning('') @@ -218,43 +303,140 @@ export function App({ gw }: { gw: GatewayClient }) { const clearIn = () => { setInput('') setInputBuf([]) + setPasteReview(null) setQueueEdit(null) setHistoryIdx(null) historyDraftRef.current = '' } - const expandPastes = (text: string) => - text.replace(PASTE_REF_RE, (m, path) => { - try { - return readFileSync(path, 'utf8') - } catch { - return m + const listPasteIds = useCallback((text: string) => { + const ids = new Set() + + for (const m of text.matchAll(PASTE_TOKEN_RE)) { + const id = parseInt(m[1] ?? '-1', 10) + + if (id > 0) { + ids.add(id) } - }) + } - const collapsePaste = (text: string) => { - pasteCounterRef.current += 1 - const lineCount = text.split('\n').length - const pasteDir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes'), 'pastes') - mkdirSync(pasteDir, { recursive: true }) + return [...ids] + }, []) - const pasteFile = join( - pasteDir, - `paste_${pasteCounterRef.current}_${new Date().toTimeString().slice(0, 8).replace(/:/g, '')}.txt` - ) + const resolvePasteTokens = useCallback( + (text: string) => { + const byId = new Map(pastes.map(p => [p.id, p])) + const missingIds = new Set() + const usedIds = new Set() + let redactions = 0 - writeFileSync(pasteFile, text, 'utf8') + const resolved = text.replace(PASTE_TOKEN_RE, (_match, rawId: string) => { + const id = parseInt(rawId, 10) + const paste = byId.get(id) - return `[Pasted text #${pasteCounterRef.current}: ${lineCount} lines → ${pasteFile}]` - } + if (!paste) { + missingIds.add(id) + return `[missing paste:${id}]` + } + + usedIds.add(id) + const cleaned = redactSecrets(paste.text) + redactions += cleaned.redactions + + if (paste.mode === 'inline') { + return cleaned.text + } + + const lang = paste.kind === 'code' ? 'text' : '' + const lines = cleaned.text.split('\n') + + if (paste.mode === 'excerpt') { + const clippedLines = lines.slice(0, EXCERPT_LINE_LIMIT) + let excerpt = clippedLines.join('\n') + + if (excerpt.length > EXCERPT_CHAR_LIMIT) { + excerpt = excerpt.slice(0, EXCERPT_CHAR_LIMIT).trimEnd() + '…' + } + + const isTruncated = lines.length > EXCERPT_LINE_LIMIT || cleaned.text.length > excerpt.length + const truncLabel = isTruncated ? `\n…[paste #${id} truncated]` : '' + + return `[paste #${id} excerpt]\n\`\`\`${lang}\n${excerpt}${truncLabel}\n\`\`\`` + } + + return `[paste #${id} attached · ${paste.lineCount} lines]\n\`\`\`${lang}\n${cleaned.text}\n\`\`\`` + }) + + return { + missingIds: [...missingIds], + redactions, + text: resolved, + usedIds: [...usedIds] + } + }, + [pastes] + ) + + const handleTextPaste = useCallback( + ({ cursor, text, value }: { cursor: number; text: string; value: string }) => { + pasteCounterRef.current += 1 + const id = pasteCounterRef.current + const charCount = text.length + const lineCount = text.split('\n').length + const mode: PasteMode = + lineCount > SMALL_PASTE_LINE_LIMIT || charCount > SMALL_PASTE_CHAR_LIMIT ? 'attach' : 'excerpt' + const token = tokenForPaste(id) + const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' + const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' + const insert = `${lead}${token}${tail}` + + setPastes(prev => + [ + ...prev, + { + charCount, + createdAt: Date.now(), + id, + kind: classifyPaste(text), + lineCount, + mode, + text + } + ].slice(-24) + ) + pushActivity(`captured ${lineCount}L paste as ${token} (${mode})`) + + return { + cursor: cursor + insert.length, + value: value.slice(0, cursor) + insert + value.slice(cursor) + } + }, + [pushActivity] + ) const send = (text: string) => { + const payload = resolvePasteTokens(text) + + if (payload.missingIds.length) { + setStatus('missing paste token') + pushActivity(`missing paste token(s): ${payload.missingIds.join(', ')}`, 'warn') + return + } + + if (payload.redactions > 0) { + pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') + } + + inflightPasteIdsRef.current = payload.usedIds setLastUserMsg(text) + setIntroCollapsed(true) appendMessage({ role: 'user', text }) setBusy(true) + setStatus('running…') buf.current = '' interruptedRef.current = false - gw.request('prompt.submit', { session_id: sid, text: expandPastes(text) }).catch((e: Error) => { + gw.request('prompt.submit', { session_id: sid, text: payload.text }).catch((e: Error) => { + inflightPasteIdsRef.current = [] sys(`error: ${e.message}`) setStatus('ready') setBusy(false) @@ -286,7 +468,7 @@ export function App({ gw }: { gw: GatewayClient }) { const paste = () => rpc('clipboard.paste', { session_id: sid }).then((r: any) => - sys(r.attached ? `📎 image #${r.count} attached` : r.message || 'no image in clipboard') + pushActivity(r.attached ? `image #${r.count} attached` : r.message || 'no image in clipboard') ) const openEditor = () => { @@ -336,8 +518,116 @@ export function App({ gw }: { gw: GatewayClient }) { }) } + const dispatchSubmission = useCallback( + (full: string, allowLarge = false) => { + if (!full.trim() || !sid) { + return + } + + const clearInput = () => { + setInputBuf([]) + setInput('') + setHistoryIdx(null) + historyDraftRef.current = '' + } + + // Slash commands and shell — route immediately, no paste logic needed + if (full.startsWith('/') && slashRef.current(full)) { + clearInput() + return + } + + if (full.startsWith('!')) { + clearInput() + shellExec(full.slice(1).trim()) + return + } + + // Paste token validation for non-command text + const missing = resolvePasteTokens(full).missingIds + + if (missing.length) { + setStatus('missing paste token') + pushActivity(`missing paste token(s): ${missing.join(', ')}`, 'warn') + return + } + + const largeIds = listPasteIds(full).filter(id => { + const paste = pastes.find(p => p.id === id) + + return !!paste && (paste.charCount >= LARGE_PASTE_CHAR_LIMIT || paste.lineCount >= LARGE_PASTE_LINE_LIMIT) + }) + + if (!allowLarge && largeIds.length) { + setPasteReview({ largeIds, text: full }) + setStatus(`review large paste (${largeIds.length})`) + return + } + + clearInput() + + const editIdx = queueEditRef.current + + if (editIdx !== null) { + replaceQ(editIdx, full) + const picked = queueRef.current.splice(editIdx, 1)[0] + syncQueue() + setQueueEdit(null) + + if (picked && busy && sid) { + queueRef.current.unshift(picked) + syncQueue() + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + setStatus('interrupting…') + return + } + + if (picked && sid) { + send(picked) + } + + return + } + + pushHistory(full) + + if (busy) { + if (hasInterpolation(full)) { + interpolate(full, enqueue) + return + } + + enqueue(full) + return + } + + if (hasInterpolation(full)) { + setBusy(true) + interpolate(full, send) + return + } + + send(full) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [busy, enqueue, gw, listPasteIds, pastes, resolvePasteTokens, sid] + ) + useInput((ch, key) => { if (blocked) { + if (pasteReview) { + if (key.return) { + const text = pasteReview.text + setPasteReview(null) + dispatchSubmission(text, true) + } else if (key.escape || (key.ctrl && ch === 'c')) { + setPasteReview(null) + setStatus('ready') + } + + return + } + if (key.ctrl && ch === 'c') { if (approval) { gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) @@ -354,6 +644,8 @@ export function App({ gw }: { gw: GatewayClient }) { } else if (picker) { setPicker(false) } + } else if (key.escape && picker) { + setPicker(false) } return @@ -514,6 +806,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.start': setThinking(true) + setTurnKey(k => k + 1) setBusy(true) setReasoning('') setThinkingText('') @@ -526,7 +819,10 @@ export function App({ gw }: { gw: GatewayClient }) { if (p.kind && p.kind !== 'status' && lastStatusNoteRef.current !== p.text) { lastStatusNoteRef.current = p.text - sys(p.text) + pushActivity( + p.text, + p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' + ) } } @@ -537,7 +833,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!protocolWarnedRef.current) { protocolWarnedRef.current = true - sys('protocol noise detected · /logs to inspect') + pushActivity('protocol noise detected · /logs to inspect', 'warn') } break @@ -576,7 +872,7 @@ export function App({ gw }: { gw: GatewayClient }) { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' - appendMessage({ role: 'tool', text: `${label}${ctx ? ': ' + ctx : ''} ${mark}` }) + pushActivity(`${label}${ctx ? ': ' + ctx : ''} ${mark}`, p.error ? 'error' : 'info') return prev.filter(t => t.id !== p.tool_id) }) @@ -632,6 +928,11 @@ export function App({ gw }: { gw: GatewayClient }) { idle() setStreaming('') + if (inflightPasteIdsRef.current.length) { + setPastes(prev => prev.filter(paste => !inflightPasteIdsRef.current.includes(paste.id))) + inflightPasteIdsRef.current = [] + } + if (!wasInterrupted) { appendMessage({ role: 'assistant' as const, text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) } @@ -650,21 +951,14 @@ export function App({ gw }: { gw: GatewayClient }) { const next = dequeue() if (next) { - setLastUserMsg(next) - appendMessage({ role: 'user' as const, text: next }) - 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) - }) + send(next) } break } case 'error': + inflightPasteIdsRef.current = [] sys(`error: ${p?.message}`) idle() setStatus('ready') @@ -673,20 +967,23 @@ export function App({ gw }: { gw: GatewayClient }) { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [appendMessage, gw, sys, newSession] + [appendMessage, dequeue, newSession, pushActivity, send, sys] ) + const onExit = useCallback(() => { + setStatus('gateway exited') + exit() + }, [exit]) + useEffect(() => { gw.on('event', onEvent) - gw.on('exit', () => { - setStatus('gateway exited') - exit() - }) + gw.on('exit', onExit) return () => { gw.off('event', onEvent) + gw.off('exit', onExit) } - }, [exit, gw, onEvent]) + }, [gw, onEvent, onExit]) const slash = useCallback( (cmd: string): boolean => { @@ -762,7 +1059,81 @@ export function App({ gw }: { gw: GatewayClient }) { } case 'paste': - paste() + if (!arg) { + paste() + + return true + } + + if (arg === 'list') { + if (!pastes.length) { + sys('no text pastes') + } else { + sys( + pastes + .map( + p => + `#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` + ) + .join('\n') + ) + } + + return true + } + + if (arg === 'clear') { + setPastes([]) + setInput(v => v.replace(PASTE_TOKEN_RE, '').replace(/\s{2,}/g, ' ').trim()) + setInputBuf(prev => + prev + .map(line => line.replace(PASTE_TOKEN_RE, '').replace(/\s{2,}/g, ' ').trim()) + .filter(Boolean) + ) + pushActivity('cleared paste shelf') + + return true + } + + if (arg.startsWith('drop ')) { + const id = parseInt(arg.split(/\s+/)[1] ?? '-1', 10) + + if (!id || !pastes.some(p => p.id === id)) { + sys('usage: /paste drop ') + return true + } + + setPastes(prev => prev.filter(p => p.id !== id)) + setInput(v => v.replace(new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g'), ' ').replace(/\s{2,}/g, ' ').trim()) + setInputBuf(prev => + prev + .map(line => + line.replace(new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g'), ' ').replace(/\s{2,}/g, ' ').trim() + ) + .filter(Boolean) + ) + pushActivity(`dropped paste #${id}`) + + return true + } + + if (arg.startsWith('mode ')) { + const [, rawId, rawMode] = arg.split(/\s+/) + const id = parseInt(rawId ?? '-1', 10) + const mode = rawMode as PasteMode + + if (!id || !['attach', 'excerpt', 'inline'].includes(mode) || !pastes.some(p => p.id === id)) { + sys('usage: /paste mode ') + return true + } + + setPastes(prev => prev.map(p => (p.id === id ? { ...p, mode } : p))) + pushActivity(`paste #${id} mode → ${mode}`) + + return true + } + + sys('usage: /paste [list|mode |drop |clear]') return true case 'logs': { @@ -905,6 +1276,7 @@ export function App({ gw }: { gw: GatewayClient }) { rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { if (r?.session_id) { setSid(r.session_id) + setHistoryItems([]) setMessages([]) sys(`branched → ${r.title}`) } @@ -924,7 +1296,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'usage': rpc('session.usage', { session_id: sid }).then((r: any) => { - if (r) setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) + if (r) { setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) } sys(`${fmtK(r?.input ?? 0)} in · ${fmtK(r?.output ?? 0)} out · ${fmtK(r?.total ?? 0)} total · ${r?.calls ?? 0} calls`) }) return true @@ -941,10 +1313,6 @@ export function App({ gw }: { gw: GatewayClient }) { rpc('config.get', { key: 'profile' }).then((r: any) => sys(r.display || r.home)) return true - case 'provider': - rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) - return true - case 'voice': if (arg === 'on' || arg === 'off') { rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice ${r.enabled ? 'on' : 'off'}`)) @@ -963,7 +1331,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [sub, ...rArgs] = (arg || 'list').split(/\s+/) if (!sub || sub === 'list') { rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r.checkpoints?.length) return sys('no checkpoints') + if (!r.checkpoints?.length) { return sys('no checkpoints') } sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) }) } else { @@ -986,7 +1354,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'plugins': rpc('plugins.list', {}).then((r: any) => { - if (!r.plugins?.length) return sys('no plugins') + if (!r.plugins?.length) { return sys('no plugins') } sys(r.plugins.map((p: any) => ` ${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`).join('\n')) }) return true @@ -1021,9 +1389,11 @@ export function App({ gw }: { gw: GatewayClient }) { } }, // eslint-disable-next-line react-hooks/exhaustive-deps - [catalog, compact, gw, info, lastUserMsg, messages, newSession, rpc, send, sid, status, sys, usage, statusBar] + [catalog, compact, gw, lastUserMsg, messages, newSession, pastes, pushActivity, rpc, send, sid, statusBar, sys] ) + slashRef.current = slash + const submit = useCallback( (value: string) => { if (!value.trim() && !inputBuf.length) { @@ -1054,7 +1424,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (next && sid) { setQueueEdit(null) - send(next) + dispatchSubmission(next, true) } } @@ -1071,80 +1441,10 @@ export function App({ gw }: { gw: GatewayClient }) { } const full = [...inputBuf, value].join('\n') - setInputBuf([]) - setInput('') - setHistoryIdx(null) - historyDraftRef.current = '' - - if (!full.trim() || !sid) { - return - } - - const editIdx = queueEditRef.current - - if (editIdx !== null && !full.startsWith('/') && !full.startsWith('!')) { - replaceQ(editIdx, full) - const picked = queueRef.current.splice(editIdx, 1)[0] - syncQueue() - setQueueEdit(null) - - if (picked && busy && sid) { - queueRef.current.unshift(picked) - syncQueue() - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - setStatus('interrupting…') - - return - } - - if (picked && sid) { - send(picked) - - return - } - - return - } - - if (editIdx !== null) { - setQueueEdit(null) - } - - pushHistory(full) - - if (busy && !full.startsWith('/') && !full.startsWith('!')) { - if (hasInterpolation(full)) { - interpolate(full, enqueue) - - return - } - - enqueue(full) - - return - } - - if (full.startsWith('!')) { - shellExec(full.slice(1).trim()) - - return - } - - if (full.startsWith('/') && slash(full)) { - return - } - - if (hasInterpolation(full)) { - setBusy(true) - interpolate(full, send) - - return - } - - send(full) + dispatchSubmission(full) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [busy, gw, inputBuf, sid, slash, sys] + [dequeue, dispatchSubmission, inputBuf, sid] ) const statusColor = @@ -1163,8 +1463,16 @@ export function App({ gw }: { gw: GatewayClient }) { {m.kind === 'intro' && m.info ? ( - - + {introCollapsed ? ( + + {theme.brand.icon} {theme.brand.name} · {m.info.model.split('/').pop()} + + ) : ( + <> + + + + )} ) : ( @@ -1180,103 +1488,130 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {(thinking || tools.length > 0) && !streaming && } + {(thinking || tools.length > 0) && (!streaming || tools.length > 0) && } + + + {pasteReview && ( + + + Review large paste before send + + pastes: {pasteReview.largeIds.map(id => `#${id}`).join(', ')} + Enter to send · Esc/Ctrl+C to cancel + + )} {clarify && ( - { - gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) - appendMessage({ role: 'user', text: answer }) - setClarify(null) - }} - req={clarify} - t={theme} - /> + + { + gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) + appendMessage({ role: 'user', text: answer }) + setClarify(null) + }} + req={clarify} + t={theme} + /> + )} {approval && ( - { - gw.request('approval.respond', { choice, session_id: sid }).catch(() => {}) - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }} - req={approval} - t={theme} - /> + + { + gw.request('approval.respond', { choice, session_id: sid }).catch(() => {}) + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }} + req={approval} + t={theme} + /> + )} {sudo && ( - { - gw.request('sudo.respond', { request_id: sudo.requestId, password }).catch(() => {}) - setSudo(null) - setStatus('running…') - }} - t={theme} - /> + + { + gw.request('sudo.respond', { request_id: sudo.requestId, password }).catch(() => {}) + setSudo(null) + setStatus('running…') + }} + t={theme} + /> + )} {secret && ( - { - gw.request('secret.respond', { request_id: secret.requestId, value }).catch(() => {}) - setSecret(null) - setStatus('running…') - }} - sub={`for ${secret.envVar}`} - t={theme} - /> + + { + gw.request('secret.respond', { request_id: secret.requestId, value }).catch(() => {}) + setSecret(null) + setStatus('running…') + }} + sub={`for ${secret.envVar}`} + t={theme} + /> + )} {picker && ( - setPicker(false)} - onSelect={id => { - setPicker(false) - setStatus('resuming…') - gw.request('session.resume', { session_id: id, cols }) - .then((r: any) => { - setSid(r.session_id) - setMessages([]) - setInfo(r.info ?? null) + + setPicker(false)} + onSelect={id => { + setPicker(false) + setStatus('resuming…') + gw.request('session.resume', { cols, session_id: id }) + .then((r: any) => { + setSid(r.session_id) + setHistoryItems([]) + setMessages([]) + setInfo(r.info ?? null) + setPastes([]) + setActivity([]) + setIntroCollapsed(false) - if (r.info) { - appendHistory(introMsg(r.info)) - } + if (r.info) { + appendHistory(introMsg(r.info)) + } - setUsage(ZERO) - lastStatusNoteRef.current = '' - protocolWarnedRef.current = false - sys(`resumed session (${r.message_count} messages)`) - setStatus('ready') - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - }) - }} - t={theme} - /> + setUsage(ZERO) + lastStatusNoteRef.current = '' + protocolWarnedRef.current = false + sys(`resumed session (${r.message_count} messages)`) + setStatus('ready') + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + }) + }} + t={theme} + /> + )} - 0 && `${fmtK(usage.total)} tok`]} - statusColor={statusColor} - /> + {statusBar && ( + 0 && `${fmtK(usage.total)} tok`]} + statusColor={statusColor} + /> + )} {!blocked && ( @@ -1288,7 +1623,7 @@ export function App({ gw }: { gw: GatewayClient }) { + {visible.map(item => { + const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim + + return ( + + {t.brand.tool} {item.text} + + ) + })} + + ) +} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 5e8b843540..676e10cb4b 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -53,9 +53,7 @@ export const MessageLine = memo(function MessageLine({ })() return ( - - {(msg.role === 'user' || msg.role === 'assistant') && } - + diff --git a/ui-tui/src/components/pasteShelf.tsx b/ui-tui/src/components/pasteShelf.tsx new file mode 100644 index 0000000000..717a1a798e --- /dev/null +++ b/ui-tui/src/components/pasteShelf.tsx @@ -0,0 +1,47 @@ +import { Box, Text } from 'ink' + +import { compactPreview } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { PendingPaste } from '../types.js' + +const TOKEN_RE = /\[\[paste:(\d+)\]\]/g + +const modeLabel = { + attach: 'attach', + excerpt: 'excerpt', + inline: 'inline' +} as const + +export function PasteShelf({ draft, pastes, t }: { draft: string; pastes: PendingPaste[]; t: Theme }) { + if (!pastes.length) { + return null + } + + const inDraft = new Set() + + for (const m of draft.matchAll(TOKEN_RE)) { + inDraft.add(parseInt(m[1] ?? '-1', 10)) + } + + return ( + + Paste shelf ({pastes.length}) + {pastes.slice(-4).map(paste => ( + + #{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {paste.kind} + {inDraft.has(paste.id) ? · in draft : ''} + {' · '} + {compactPreview(paste.text, 44) || '(empty)'} + + ))} + {pastes.length > 4 && ( + + …and {pastes.length - 4} more + + )} + + /paste mode {''} {''} · /paste drop {''} · /paste clear + + + ) +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 389fd27c3e..9e7e5ab10c 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -41,12 +41,12 @@ interface Props { value: string onChange: (v: string) => void onSubmit?: (v: string) => void - onLargePaste?: (text: string) => string + onPaste?: (data: { cursor: number; text: string; value: string }) => { cursor: number; value: string } | null placeholder?: string focus?: boolean } -export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder = '', focus = true }: Props) { +export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { const [cur, setCur] = useState(value.length) const vRef = useRef(value) const selfChange = useRef(false) @@ -74,22 +74,21 @@ export function TextInput({ value, onChange, onSubmit, onLargePaste, placeholder } const v = vRef.current + const handled = onPaste?.({ cursor: at, text: pasted, value: v }) - if (pasted.split('\n').length >= 5 || pasted.length > 500) { - const ph = onLargePaste?.(pasted) ?? pasted.replace(/\n/g, ' ') - const nv = v.slice(0, at) + ph + v.slice(at) + if (handled) { + selfChange.current = true + onChange(handled.value) + setCur(handled.cursor) + + return + } + + if (pasted.length && PRINTABLE.test(pasted)) { + const nv = v.slice(0, at) + pasted + v.slice(at) selfChange.current = true onChange(nv) - setCur(at + ph.length) - } else { - const clean = pasted.replace(/\n/g, ' ') - - if (clean.length && PRINTABLE.test(clean)) { - const nv = v.slice(0, at) + clean + v.slice(at) - selfChange.current = true - onChange(nv) - setCur(at + clean.length) - } + setCur(at + pasted.length) } } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 183401f8f4..a9f4ceede6 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,21 +1,27 @@ import { Text } from 'ink' import { memo, useEffect, useState } from 'react' +import spinners, { type BrailleSpinnerName } from 'unicode-animations' -import { FACES, SPINNER, TOOL_VERBS, VERBS } from '../constants.js' -import { pick } from '../lib/text.js' +import { FACES, TOOL_VERBS, VERBS } from '../constants.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -function Spinner({ color }: { color: string }) { +const THINK_POOL: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] +const TOOL_POOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] + +const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]! + +function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { + const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL_POOL : THINK_POOL)]) const [i, setI] = useState(0) useEffect(() => { - const id = setInterval(() => setI(p => (p + 1) % SPINNER.length), 80) + const id = setInterval(() => setI(p => (p + 1) % spin.frames.length), spin.interval) return () => clearInterval(id) - }, []) + }, [spin]) - return {SPINNER[i]} + return {spin.frames[i]} } export const Thinking = memo(function Thinking({ @@ -27,25 +33,25 @@ export const Thinking = memo(function Thinking({ t: Theme tools: ActiveTool[] }) { - const [verb, setVerb] = useState(() => pick(VERBS)) - const [face, setFace] = useState(() => pick(FACES)) + const [tick, setTick] = useState(0) useEffect(() => { const id = setInterval(() => { - setVerb(pick(VERBS)) - setFace(pick(FACES)) + setTick(v => v + 1) }, 1100) return () => clearInterval(id) }, []) + const verb = VERBS[tick % VERBS.length] ?? 'thinking' + const face = FACES[tick % FACES.length] ?? '(•_•)' const tail = reasoning.slice(-160).replace(/\n/g, ' ') return ( <> {tools.map(tool => ( - {TOOL_VERBS[tool.name] ?? tool.name} + {TOOL_VERBS[tool.name] ?? tool.name} {tool.context ? `: ${tool.context}` : ''} ))} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 83660c4cb3..f72ce34bfc 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -60,8 +60,6 @@ export const ROLE: Record { body: string; glyph: string; pre user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) } -export const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - export const TOOL_VERBS: Record = { browser: '🌐 browsing', clarify: '❓ asking', diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 8a34f3cb75..7d287dc38b 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -4,6 +4,12 @@ export interface ActiveTool { context?: string } +export interface ActivityItem { + id: number + text: string + tone: 'error' | 'info' | 'warn' +} + export interface ApprovalReq { command: string description: string @@ -53,6 +59,19 @@ export interface SecretReq { requestId: string } +export type PasteKind = 'code' | 'log' | 'text' +export type PasteMode = 'attach' | 'excerpt' | 'inline' + +export interface PendingPaste { + charCount: number + createdAt: number + id: number + kind: PasteKind + lineCount: number + mode: PasteMode + text: string +} + /** From `commands.catalog` — mirrors hermes_cli.commands COMMANDS + SUBCOMMANDS + skills. */ export interface SlashCatalog { canon: Record From b59712348948b21ff48084c39aba6ffd142253a3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 14:18:37 -0500 Subject: [PATCH 030/157] feat: better bg tasks --- ui-tui/package-lock.json | 5383 ------------------------ ui-tui/src/app.tsx | 558 +-- ui-tui/src/components/activityLane.tsx | 19 +- ui-tui/src/components/messageLine.tsx | 3 +- ui-tui/src/components/textInput.tsx | 48 +- ui-tui/src/components/thinking.tsx | 12 +- ui-tui/src/constants.ts | 2 - ui-tui/src/theme.ts | 4 +- ui-tui/src/types.ts | 5 +- 9 files changed, 364 insertions(+), 5670 deletions(-) delete mode 100644 ui-tui/package-lock.json diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json deleted file mode 100644 index 85d196129c..0000000000 --- a/ui-tui/package-lock.json +++ /dev/null @@ -1,5383 +0,0 @@ -{ - "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": { - "@eslint/js": "^9", - "@types/node": "^25.5.0", - "@types/react": "^19.2.14", - "@typescript-eslint/eslint-plugin": "^8", - "@typescript-eslint/parser": "^8", - "eslint": "^9", - "eslint-plugin-perfectionist": "^5", - "eslint-plugin-react": "^7", - "eslint-plugin-react-hooks": "^7", - "eslint-plugin-unused-imports": "^4", - "globals": "^16", - "prettier": "^3", - "tsx": "^4.19.0", - "typescript": "^5.7.0" - } - }, - "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/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "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/@eslint-community/eslint-utils": { - "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.5" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "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==", - "devOptional": true, - "license": "MIT", - "peer": true, - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", - "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/type-utils": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "ignore": "^7.0.5", - "natural-compare": "^1.4.0", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^8.58.0", - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", - "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", - "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", - "debug": "^4.4.3" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", - "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", - "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", - "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "debug": "^4.4.3", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", - "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", - "debug": "^4.4.3", - "minimatch": "^10.2.2", - "semver": "^7.7.3", - "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.5.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", - "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.1.0" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", - "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.58.0", - "eslint-visitor-keys": "^5.0.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "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/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.3", - "es-errors": "^1.3.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "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/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "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/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "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/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "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==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", - "dev": true, - "license": "ISC" - }, - "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-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-iterator-helpers": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", - "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.1.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.3.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0", - "safe-array-concat": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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/eslint": { - "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.2", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.5", - "@eslint/js": "9.39.4", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.14.0", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.5", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-plugin-perfectionist": { - "version": "5.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.8.0.tgz", - "integrity": "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@typescript-eslint/utils": "^8.58.0", - "natural-orderby": "^5.0.0" - }, - "engines": { - "node": "^20.0.0 || >=22.0.0" - }, - "peerDependencies": { - "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.8", - "array.prototype.findlast": "^1.2.5", - "array.prototype.flatmap": "^1.3.3", - "array.prototype.tosorted": "^1.1.4", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.2.1", - "estraverse": "^5.3.0", - "hasown": "^2.0.2", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.9", - "object.fromentries": "^2.0.8", - "object.values": "^1.2.1", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.12", - "string.prototype.repeat": "^1.0.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.24.4", - "@babel/parser": "^7.24.4", - "hermes-parser": "^0.25.1", - "zod": "^3.25.0 || ^4.0.0", - "zod-validation-error": "^3.5.0 || ^4.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-unused-imports": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", - "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", - "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@typescript-eslint/eslint-plugin": { - "optional": true - } - } - }, - "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.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-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/globals": { - "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hermes-estree": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", - "dev": true, - "license": "MIT" - }, - "node_modules/hermes-parser": { - "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hermes-estree": "0.25.1" - } - }, - "node_modules/ignore": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "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/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "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/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/iterator.prototype": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "get-proto": "^1.0.0", - "has-symbols": "^1.1.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "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/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/natural-orderby": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", - "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/node-exports-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "array.prototype.flatmap": "^1.3.3", - "es-errors": "^1.3.0", - "object.entries": "^1.1.9", - "semver": "^6.3.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/node-exports-info/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", - "dev": true, - "license": "MIT" - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.entries": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "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/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "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", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, - "license": "MIT" - }, - "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/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve": { - "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "node-exports-info": "^1.6.0", - "object-keys": "^1.1.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "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/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "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/string.prototype.matchall": { - "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.6", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "internal-slot": "^1.1.0", - "regexp.prototype.flags": "^1.5.3", - "set-function-name": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.repeat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.1.3", - "es-abstract": "^1.17.5" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/ts-api-utils": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.12" - }, - "peerDependencies": { - "typescript": ">=4.8.4" - } - }, - "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-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "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/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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", - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "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/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "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/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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" - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "dev": true, - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-validation-error": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.25.0 || ^4.0.0" - } - } - } -} diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 31be2cb9f5..94144b9aad 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -38,14 +38,14 @@ import type { Usage } from './types.js' +// ── Constants ──────────────────────────────────────────────────────── + const PLACEHOLDER = pick(PLACEHOLDERS) const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g -const EXCERPT_CHAR_LIMIT = 1200 -const EXCERPT_LINE_LIMIT = 14 -const LARGE_PASTE_CHAR_LIMIT = 8000 -const LARGE_PASTE_LINE_LIMIT = 80 -const SMALL_PASTE_CHAR_LIMIT = 400 -const SMALL_PASTE_LINE_LIMIT = 4 + +const SMALL_PASTE = { chars: 400, lines: 4 } +const LARGE_PASTE = { chars: 8000, lines: 80 } +const EXCERPT = { chars: 1200, lines: 14 } const SECRET_PATTERNS = [ /AKIA[0-9A-Z]{16}/g, @@ -57,24 +57,18 @@ const SECRET_PATTERNS = [ /\b(?:api[_-]?key|token|secret)\b\s*[:=]\s*["']?[A-Za-z0-9_-]{12,}/gi ] -const introMsg = (info: SessionInfo): Msg => ({ - role: 'system', - text: '', - kind: 'intro', - info -}) +// ── Pure helpers ───────────────────────────────────────────────────── + +const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) const classifyPaste = (text: string): PendingPaste['kind'] => { - const t = text.toLowerCase() - const lines = text.split('\n') - - if (/error|warn|traceback|exception|stack|debug|\[\d{2}:\d{2}:\d{2}\]/.test(t)) { + if (/error|warn|traceback|exception|stack|debug|\[\d{2}:\d{2}:\d{2}\]/i.test(text)) { return 'log' } if ( /```|function\s+\w+|class\s+\w+|import\s+.+from|const\s+\w+\s*=|def\s+\w+\(|<\w+/.test(text) || - lines.filter(line => /[{}()[\];<>]/.test(line)).length >= 3 + text.split('\n').filter(l => /[{}()[\];<>]/.test(l)).length >= 3 ) { return 'code' } @@ -83,26 +77,32 @@ const classifyPaste = (text: string): PendingPaste['kind'] => { } const redactSecrets = (text: string) => { - let output = text let redactions = 0 - for (const pattern of SECRET_PATTERNS) { - output = output.replace(pattern, value => { - redactions += 1 + const cleaned = SECRET_PATTERNS.reduce( + (t, pat) => + t.replace(pat, val => { + redactions++ - if (value.includes(':') || value.includes('=')) { - const [head] = value.split(/[:=]/) - return `${head}: [REDACTED_SECRET]` - } + return val.includes(':') || val.includes('=') + ? `${val.split(/[:=]/)[0]}: [REDACTED_SECRET]` + : '[REDACTED_SECRET]' + }), + text + ) - return '[REDACTED_SECRET]' - }) - } - - return { redactions, text: output } + return { redactions, text: cleaned } } -const tokenForPaste = (id: number) => `[[paste:${id}]]` +const pasteToken = (id: number) => `[[paste:${id}]]` + +const stripTokens = (text: string, re: RegExp) => + text + .replace(re, '') + .replace(/\s{2,}/g, ' ') + .trim() + +// ── StatusRule ──────────────────────────────────────────────────────── function StatusRule({ cols, @@ -119,7 +119,6 @@ function StatusRule({ }) { const label = parts.filter(Boolean).join(' · ') const lead = String(parts[0] ?? '') - const fill = Math.max(0, cols - label.length - 5) return ( @@ -128,11 +127,23 @@ function StatusRule({ {parts[0]} {label.slice(lead.length)} - {' ' + '─'.repeat(fill)} + {' ' + '─'.repeat(Math.max(0, cols - label.length - 5))} ) } +// ── PromptBox ──────────────────────────────────────────────────────── + +function PromptBox({ children, color }: { children: React.ReactNode; color: string }) { + return ( + + {children} + + ) +} + +// ── App ────────────────────────────────────────────────────────────── + export function App({ gw }: { gw: GatewayClient }) { const { exit } = useApp() const { stdout } = useStdout() @@ -151,6 +162,8 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [stdout]) + // ── State ──────────────────────────────────────────────────────── + const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) const [messages, setMessages] = useState([]) @@ -179,8 +192,11 @@ export function App({ gw }: { gw: GatewayClient }) { const [pastes, setPastes] = useState([]) const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null) const [streaming, setStreaming] = useState('') + const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) + // ── Refs ───────────────────────────────────────────────────────── + const activityIdRef = useRef(0) const buf = useRef('') const inflightPasteIdsRef = useRef([]) @@ -190,14 +206,24 @@ export function App({ gw }: { gw: GatewayClient }) { const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const pasteCounterRef = useRef(0) + const colsRef = useRef(cols) + colsRef.current = cols + + // ── Hooks ──────────────────────────────────────────────────────── const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = useQueue() - const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() + const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) + + function blocked() { + return !!(clarify || approval || pasteReview || picker || secret || sudo) + } const empty = !messages.length - const blocked = !!(clarify || approval || pasteReview || picker || secret || sudo) + const isBlocked = blocked() + + // ── Resize RPC ─────────────────────────────────────────────────── useEffect(() => { if (!sid || !stdout) { @@ -212,7 +238,7 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps - const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked, gw) + // ── Core actions ───────────────────────────────────────────────── const appendMessage = useCallback((msg: Msg) => { setMessages(prev => [...prev, msg]) @@ -231,14 +257,12 @@ export function App({ gw }: { gw: GatewayClient }) { return prev } - activityIdRef.current += 1 + activityIdRef.current++ + return [...prev, { id: activityIdRef.current, text, tone }].slice(-8) }) }, []) - const colsRef = useRef(cols) - colsRef.current = cols - const rpc = useCallback( (method: string, params: Record = {}) => gw.request(method, params).catch((e: Error) => { @@ -247,38 +271,6 @@ export function App({ gw }: { gw: GatewayClient }) { [gw, sys] ) - const newSession = useCallback( - (msg?: string) => - rpc('session.create', { cols: colsRef.current }).then((r: any) => { - if (!r) { - return - } - - setSid(r.session_id) - setHistoryItems([]) - setMessages([]) - setPastes([]) - setActivity([]) - setUsage(ZERO) - setStatus('ready') - setIntroCollapsed(false) - lastStatusNoteRef.current = '' - protocolWarnedRef.current = false - - if (r.info) { - setInfo(r.info) - appendHistory(introMsg(r.info)) - } else { - setInfo(null) - } - - if (msg) { - sys(msg) - } - }), - [appendHistory, rpc, sys] - ) - const idle = () => { setThinking(false) setTools([]) @@ -309,6 +301,48 @@ export function App({ gw }: { gw: GatewayClient }) { historyDraftRef.current = '' } + const resetSession = () => { + setSid(null as any) // will be set by caller + setHistoryItems([]) + setMessages([]) + setPastes([]) + setActivity([]) + setBgTasks(new Set()) + setIntroCollapsed(false) + setUsage(ZERO) + lastStatusNoteRef.current = '' + protocolWarnedRef.current = false + } + + // ── Session management ─────────────────────────────────────────── + + const newSession = useCallback( + (msg?: string) => + rpc('session.create', { cols: colsRef.current }).then((r: any) => { + if (!r) { + return + } + + resetSession() + setSid(r.session_id) + setStatus('ready') + + if (r.info) { + setInfo(r.info) + appendHistory(introMsg(r.info)) + } else { + setInfo(null) + } + + if (msg) { + sys(msg) + } + }), + [appendHistory, rpc, sys] + ) + + // ── Paste pipeline ─────────────────────────────────────────────── + const listPasteIds = useCallback((text: string) => { const ids = new Set() @@ -330,12 +364,13 @@ export function App({ gw }: { gw: GatewayClient }) { const usedIds = new Set() let redactions = 0 - const resolved = text.replace(PASTE_TOKEN_RE, (_match, rawId: string) => { + const resolved = text.replace(PASTE_TOKEN_RE, (_m, rawId: string) => { const id = parseInt(rawId, 10) const paste = byId.get(id) if (!paste) { missingIds.add(id) + return `[missing paste:${id}]` } @@ -351,41 +386,33 @@ export function App({ gw }: { gw: GatewayClient }) { const lines = cleaned.text.split('\n') if (paste.mode === 'excerpt') { - const clippedLines = lines.slice(0, EXCERPT_LINE_LIMIT) - let excerpt = clippedLines.join('\n') + let excerpt = lines.slice(0, EXCERPT.lines).join('\n') - if (excerpt.length > EXCERPT_CHAR_LIMIT) { - excerpt = excerpt.slice(0, EXCERPT_CHAR_LIMIT).trimEnd() + '…' + if (excerpt.length > EXCERPT.chars) { + excerpt = excerpt.slice(0, EXCERPT.chars).trimEnd() + '…' } - const isTruncated = lines.length > EXCERPT_LINE_LIMIT || cleaned.text.length > excerpt.length - const truncLabel = isTruncated ? `\n…[paste #${id} truncated]` : '' + const truncated = lines.length > EXCERPT.lines || cleaned.text.length > excerpt.length + const tail = truncated ? `\n…[paste #${id} truncated]` : '' - return `[paste #${id} excerpt]\n\`\`\`${lang}\n${excerpt}${truncLabel}\n\`\`\`` + return `[paste #${id} excerpt]\n\`\`\`${lang}\n${excerpt}${tail}\n\`\`\`` } return `[paste #${id} attached · ${paste.lineCount} lines]\n\`\`\`${lang}\n${cleaned.text}\n\`\`\`` }) - return { - missingIds: [...missingIds], - redactions, - text: resolved, - usedIds: [...usedIds] - } + return { missingIds: [...missingIds], redactions, text: resolved, usedIds: [...usedIds] } }, [pastes] ) const handleTextPaste = useCallback( ({ cursor, text, value }: { cursor: number; text: string; value: string }) => { - pasteCounterRef.current += 1 + pasteCounterRef.current++ const id = pasteCounterRef.current - const charCount = text.length const lineCount = text.split('\n').length - const mode: PasteMode = - lineCount > SMALL_PASTE_LINE_LIMIT || charCount > SMALL_PASTE_CHAR_LIMIT ? 'attach' : 'excerpt' - const token = tokenForPaste(id) + const mode: PasteMode = lineCount > SMALL_PASTE.lines || text.length > SMALL_PASTE.chars ? 'attach' : 'excerpt' + const token = pasteToken(id) const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' const insert = `${lead}${token}${tail}` @@ -394,7 +421,7 @@ export function App({ gw }: { gw: GatewayClient }) { [ ...prev, { - charCount, + charCount: text.length, createdAt: Date.now(), id, kind: classifyPaste(text), @@ -404,22 +431,22 @@ export function App({ gw }: { gw: GatewayClient }) { } ].slice(-24) ) + pushActivity(`captured ${lineCount}L paste as ${token} (${mode})`) - return { - cursor: cursor + insert.length, - value: value.slice(0, cursor) + insert + value.slice(cursor) - } + return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, [pushActivity] ) + // ── Send ───────────────────────────────────────────────────────── + const send = (text: string) => { const payload = resolvePasteTokens(text) if (payload.missingIds.length) { - setStatus('missing paste token') pushActivity(`missing paste token(s): ${payload.missingIds.join(', ')}`, 'warn') + return } @@ -435,6 +462,7 @@ export function App({ gw }: { gw: GatewayClient }) { setStatus('running…') buf.current = '' interruptedRef.current = false + gw.request('prompt.submit', { session_id: sid, text: payload.text }).catch((e: Error) => { inflightPasteIdsRef.current = [] sys(`error: ${e.message}`) @@ -447,6 +475,7 @@ export function App({ gw }: { gw: GatewayClient }) { appendMessage({ role: 'user', text: `!${cmd}` }) setBusy(true) setStatus('running…') + gw.request('shell.exec', { command: cmd }) .then((r: any) => { const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() @@ -500,10 +529,11 @@ export function App({ gw }: { gw: GatewayClient }) { const interpolate = (text: string, then: (result: string) => void) => { setStatus('interpolating…') const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + Promise.all( - matches.map(match => + matches.map(m => gw - .request('shell.exec', { command: match[1]! }) + .request('shell.exec', { command: m[1]! }) .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim()) .catch(() => '(error)') ) @@ -518,6 +548,8 @@ export function App({ gw }: { gw: GatewayClient }) { }) } + // ── Dispatch ───────────────────────────────────────────────────── + const dispatchSubmission = useCallback( (full: string, allowLarge = false) => { if (!full.trim() || !sid) { @@ -531,36 +563,37 @@ export function App({ gw }: { gw: GatewayClient }) { historyDraftRef.current = '' } - // Slash commands and shell — route immediately, no paste logic needed if (full.startsWith('/') && slashRef.current(full)) { clearInput() + return } if (full.startsWith('!')) { clearInput() shellExec(full.slice(1).trim()) + return } - // Paste token validation for non-command text - const missing = resolvePasteTokens(full).missingIds + const { missingIds } = resolvePasteTokens(full) + + if (missingIds.length) { + pushActivity(`missing paste token(s): ${missingIds.join(', ')}`, 'warn') - if (missing.length) { - setStatus('missing paste token') - pushActivity(`missing paste token(s): ${missing.join(', ')}`, 'warn') return } const largeIds = listPasteIds(full).filter(id => { - const paste = pastes.find(p => p.id === id) + const p = pastes.find(x => x.id === id) - return !!paste && (paste.charCount >= LARGE_PASTE_CHAR_LIMIT || paste.lineCount >= LARGE_PASTE_LINE_LIMIT) + return !!p && (p.charCount >= LARGE_PASTE.chars || p.lineCount >= LARGE_PASTE.lines) }) if (!allowLarge && largeIds.length) { setPasteReview({ largeIds, text: full }) setStatus(`review large paste (${largeIds.length})`) + return } @@ -579,6 +612,7 @@ export function App({ gw }: { gw: GatewayClient }) { syncQueue() gw.request('session.interrupt', { session_id: sid }).catch(() => {}) setStatus('interrupting…') + return } @@ -594,16 +628,19 @@ export function App({ gw }: { gw: GatewayClient }) { if (busy) { if (hasInterpolation(full)) { interpolate(full, enqueue) + return } enqueue(full) + return } if (hasInterpolation(full)) { setBusy(true) interpolate(full, send) + return } @@ -613,13 +650,15 @@ export function App({ gw }: { gw: GatewayClient }) { [busy, enqueue, gw, listPasteIds, pastes, resolvePasteTokens, sid] ) + // ── Input handling ─────────────────────────────────────────────── + useInput((ch, key) => { - if (blocked) { + if (isBlocked) { if (pasteReview) { if (key.return) { - const text = pasteReview.text + const t = pasteReview.text setPasteReview(null) - dispatchSubmission(text, true) + dispatchSubmission(t, true) } else if (key.escape || (key.ctrl && ch === 'c')) { setPasteReview(null) setStatus('ready') @@ -669,14 +708,12 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.upArrow && !inputBuf.length) { if (queueRef.current.length) { - const len = queueRef.current.length - const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % len + const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % queueRef.current.length setQueueEdit(idx) setHistoryIdx(null) setInput(queueRef.current[idx] ?? '') } else if (historyRef.current.length) { - const hist = historyRef.current - const idx = historyIdx === null ? hist.length - 1 : Math.max(0, historyIdx - 1) + const idx = historyIdx === null ? historyRef.current.length - 1 : Math.max(0, historyIdx - 1) if (historyIdx === null) { historyDraftRef.current = input @@ -684,7 +721,7 @@ export function App({ gw }: { gw: GatewayClient }) { setHistoryIdx(idx) setQueueEdit(null) - setInput(hist[idx] ?? '') + setInput(historyRef.current[idx] ?? '') } return @@ -692,21 +729,22 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.downArrow && !inputBuf.length) { if (queueRef.current.length) { - const len = queueRef.current.length - const idx = queueEditIdx === null ? len - 1 : (queueEditIdx - 1 + len) % len + const idx = + queueEditIdx === null + ? queueRef.current.length - 1 + : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length setQueueEdit(idx) setHistoryIdx(null) setInput(queueRef.current[idx] ?? '') } else if (historyIdx !== null) { - const hist = historyRef.current const next = historyIdx + 1 - if (next >= hist.length) { + if (next >= historyRef.current.length) { setHistoryIdx(null) setInput(historyDraftRef.current) } else { setHistoryIdx(next) - setInput(hist[next] ?? '') + setInput(historyRef.current[next] ?? '') } } @@ -717,10 +755,10 @@ export function App({ gw }: { gw: GatewayClient }) { if (busy && sid) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - const partial = (streaming || buf.current).trimStart() + if (partial) { - appendMessage({ role: 'assistant' as const, text: partial + '\n\n*[interrupted]*' }) + appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) } else { sys('interrupted') } @@ -763,6 +801,8 @@ export function App({ gw }: { gw: GatewayClient }) { } }) + // ── Gateway events ─────────────────────────────────────────────── + const onEvent = useCallback( (ev: GatewayEvent) => { const p = ev.payload as any @@ -770,7 +810,9 @@ export function App({ gw }: { gw: GatewayClient }) { switch (ev.type) { case 'gateway.ready': if (p?.skin) { - setTheme(fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {}, p.skin.banner_logo ?? '', p.skin.banner_hero ?? '')) + setTheme( + fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {}, p.skin.banner_logo ?? '', p.skin.banner_hero ?? '') + ) } rpc('commands.catalog', {}) @@ -850,24 +892,21 @@ export function App({ gw }: { gw: GatewayClient }) { setTools(prev => { const idx = prev.findIndex(t => t.name === p.name) - if (idx >= 0) { - return [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] - } - - return prev + return idx >= 0 + ? [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] + : prev }) } break - case 'tool.start': { - const ctx = (p.context as string) || '' - setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: ctx }]) + + case 'tool.start': + setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }]) break - } - case 'tool.complete': { const mark = p.error ? '✗' : '✓' + setTools(prev => { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name @@ -876,9 +915,9 @@ export function App({ gw }: { gw: GatewayClient }) { return prev.filter(t => t.id !== p.tool_id) }) - } break + } case 'clarify.request': setClarify({ choices: p.choices, question: p.question, requestId: p.request_id }) @@ -905,23 +944,33 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'background.complete': + setBgTasks(prev => { + const next = new Set(prev) + next.delete(p.task_id) + + return next + }) sys(`[bg ${p.task_id}] ${p.text}`) break case 'btw.complete': + setBgTasks(prev => { + const next = new Set(prev) + next.delete(`btw:${p.task_id ?? 'x'}`) + + return next + }) sys(`[btw] ${p.text}`) break case 'message.delta': - if (!p?.text || interruptedRef.current) { - break + if (p?.text && !interruptedRef.current) { + buf.current += p.rendered ?? p.text + setStreaming(buf.current.trimStart()) } - buf.current += p.rendered ?? p.text - setStreaming(buf.current.trimStart()) - break case 'message.complete': { const wasInterrupted = interruptedRef.current @@ -934,7 +983,7 @@ export function App({ gw }: { gw: GatewayClient }) { } if (!wasInterrupted) { - appendMessage({ role: 'assistant' as const, text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) + appendMessage({ role: 'assistant', text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) } buf.current = '' @@ -965,8 +1014,8 @@ export function App({ gw }: { gw: GatewayClient }) { break } + // eslint-disable-next-line react-hooks/exhaustive-deps }, - // eslint-disable-next-line react-hooks/exhaustive-deps [appendMessage, dequeue, newSession, pushActivity, send, sys] ) @@ -985,6 +1034,8 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [gw, onEvent, onExit]) + // ── Slash commands ─────────────────────────────────────────────── + const slash = useCallback( (cmd: string): boolean => { const [name, ...rest] = cmd.slice(1).split(/\s+/) @@ -994,11 +1045,11 @@ export function App({ gw }: { gw: GatewayClient }) { case 'help': { const rows = catalog?.pairs ?? [] const cap = 52 - const lines = rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`) + sys( [ ' Commands:', - ...lines, + ...rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`), rows.length > cap ? ` … ${rows.length - cap} more` : '', '', ' Hotkeys:', @@ -1032,14 +1083,14 @@ export function App({ gw }: { gw: GatewayClient }) { return true - case 'compact': - setCompact(c => (arg ? true : !c)) - sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) + case 'resume': + setPicker(true) return true - case 'resume': - setPicker(true) + case 'compact': + setCompact(c => (arg ? true : !c)) + sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) return true case 'copy': { @@ -1066,30 +1117,24 @@ export function App({ gw }: { gw: GatewayClient }) { } if (arg === 'list') { - if (!pastes.length) { - sys('no text pastes') - } else { - sys( - pastes - .map( - p => - `#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` - ) - .join('\n') - ) - } + sys( + pastes.length + ? pastes + .map( + p => + `#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` + ) + .join('\n') + : 'no text pastes' + ) return true } if (arg === 'clear') { setPastes([]) - setInput(v => v.replace(PASTE_TOKEN_RE, '').replace(/\s{2,}/g, ' ').trim()) - setInputBuf(prev => - prev - .map(line => line.replace(PASTE_TOKEN_RE, '').replace(/\s{2,}/g, ' ').trim()) - .filter(Boolean) - ) + setInput(v => stripTokens(v, PASTE_TOKEN_RE)) + setInputBuf(prev => prev.map(l => stripTokens(l, PASTE_TOKEN_RE)).filter(Boolean)) pushActivity('cleared paste shelf') return true @@ -1100,18 +1145,14 @@ export function App({ gw }: { gw: GatewayClient }) { if (!id || !pastes.some(p => p.id === id)) { sys('usage: /paste drop ') + return true } + const re = new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g') setPastes(prev => prev.filter(p => p.id !== id)) - setInput(v => v.replace(new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g'), ' ').replace(/\s{2,}/g, ' ').trim()) - setInputBuf(prev => - prev - .map(line => - line.replace(new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g'), ' ').replace(/\s{2,}/g, ' ').trim() - ) - .filter(Boolean) - ) + setInput(v => stripTokens(v, re)) + setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean)) pushActivity(`dropped paste #${id}`) return true @@ -1124,6 +1165,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!id || !['attach', 'excerpt', 'inline'].includes(mode) || !pastes.some(p => p.id === id)) { sys('usage: /paste mode ') + return true } @@ -1136,12 +1178,11 @@ export function App({ gw }: { gw: GatewayClient }) { sys('usage: /paste [list|mode |drop |clear]') return true - case 'logs': { - const limit = Math.min(80, Math.max(1, parseInt(arg, 10) || 20)) - sys(gw.getLogTail(limit) || 'no gateway logs') + + case 'logs': + sys(gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) || 'no gateway logs') return true - } case 'statusbar': @@ -1216,20 +1257,33 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'background': + case 'bg': if (!arg) { sys('/background ') + return true } - rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => sys(`bg ${r.task_id} started`)) + + rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => { + setBgTasks(prev => new Set(prev).add(r.task_id)) + sys(`bg ${r.task_id} started`) + }) + return true case 'btw': if (!arg) { sys('/btw ') + return true } - rpc('prompt.btw', { session_id: sid, text: arg }).then(() => sys('btw running…')) + + rpc('prompt.btw', { session_id: sid, text: arg }).then(() => { + setBgTasks(prev => new Set(prev).add('btw:x')) + sys('btw running…') + }) + return true case 'model': @@ -1241,37 +1295,45 @@ export function App({ gw }: { gw: GatewayClient }) { setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) }) } + return true case 'yolo': rpc('config.set', { key: 'yolo' }).then((r: any) => sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)) + return true case 'reasoning': rpc('config.set', { key: 'reasoning', value: arg || 'medium' }).then((r: any) => sys(`reasoning: ${r.value}`)) + return true case 'verbose': rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose: ${r.value}`)) + return true case 'personality': rpc('config.set', { key: 'personality', value: arg }).then((r: any) => sys(`personality: ${r.value || 'default'}`) ) + return true case 'compress': rpc('session.compress', { session_id: sid }).then((r: any) => sys(`compressed${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) ) + return true case 'stop': rpc('process.stop', {}).then((r: any) => sys(`killed ${r.killed ?? 0} process(es)`)) + return true case 'branch': + case 'fork': rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { if (r?.session_id) { @@ -1281,57 +1343,73 @@ export function App({ gw }: { gw: GatewayClient }) { sys(`branched → ${r.title}`) } }) + return true case 'reload-mcp': + case 'reload_mcp': rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP reloaded')) + return true case 'title': rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => sys(`title: ${r.title || '(none)'}`) ) + return true case 'usage': rpc('session.usage', { session_id: sid }).then((r: any) => { - if (r) { setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) } - sys(`${fmtK(r?.input ?? 0)} in · ${fmtK(r?.output ?? 0)} out · ${fmtK(r?.total ?? 0)} total · ${r?.calls ?? 0} calls`) + if (r) { + setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) + } + + sys( + `${fmtK(r?.input ?? 0)} in · ${fmtK(r?.output ?? 0)} out · ${fmtK(r?.total ?? 0)} total · ${r?.calls ?? 0} calls` + ) }) + return true case 'save': rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved: ${r.file}`)) + return true case 'history': rpc('session.history', { session_id: sid }).then((r: any) => sys(`${r.count} messages`)) + return true case 'profile': rpc('config.get', { key: 'profile' }).then((r: any) => sys(r.display || r.home)) + return true case 'voice': - if (arg === 'on' || arg === 'off') { - rpc('voice.toggle', { action: arg }).then((r: any) => sys(`voice ${r.enabled ? 'on' : 'off'}`)) - } else { - rpc('voice.toggle', { action: 'status' }).then((r: any) => sys(`voice: ${r.enabled ? 'on' : 'off'}`)) - } + rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => + sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`) + ) + return true case 'insights': rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => sys(`${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) ) - return true + return true case 'rollback': { const [sub, ...rArgs] = (arg || 'list').split(/\s+/) + if (!sub || sub === 'list') { rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r.checkpoints?.length) { return sys('no checkpoints') } + if (!r.checkpoints?.length) { + return sys('no checkpoints') + } + sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) }) } else { @@ -1340,34 +1418,33 @@ export function App({ gw }: { gw: GatewayClient }) { sys(r.rendered || r.diff || r.message || 'done') ) } + return true } case 'browser': { - const action = arg || 'status' - const [act, ...bArgs] = action.split(/\s+/) + const [act, ...bArgs] = (arg || 'status').split(/\s+/) rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') ) + return true } case 'plugins': rpc('plugins.list', {}).then((r: any) => { - if (!r.plugins?.length) { return sys('no plugins') } + if (!r.plugins?.length) { + return sys('no plugins') + } + sys(r.plugins.map((p: any) => ` ${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`).join('\n')) }) + return true default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - if (r?.output) { - sys(r.output) - } else { - sys(`/${name}: no output`) - } - }) + .then((r: any) => sys(r?.output || `/${name}: no output`)) .catch(() => { gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) .then((d: any) => { @@ -1387,13 +1464,15 @@ export function App({ gw }: { gw: GatewayClient }) { return true } + // eslint-disable-next-line react-hooks/exhaustive-deps }, - // eslint-disable-next-line react-hooks/exhaustive-deps [catalog, compact, gw, lastUserMsg, messages, newSession, pastes, pushActivity, rpc, send, sid, statusBar, sys] ) slashRef.current = slash + // ── Submit ─────────────────────────────────────────────────────── + const submit = useCallback( (value: string) => { if (!value.trim() && !inputBuf.length) { @@ -1404,10 +1483,10 @@ export function App({ gw }: { gw: GatewayClient }) { if (dbl && busy && sid) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - const partial = (streaming || buf.current).trimStart() + if (partial) { - appendMessage({ role: 'assistant' as const, text: partial + '\n\n*[interrupted]*' }) + appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) } else { sys('interrupted') } @@ -1440,13 +1519,14 @@ export function App({ gw }: { gw: GatewayClient }) { return } - const full = [...inputBuf, value].join('\n') - dispatchSubmission(full) + dispatchSubmission([...inputBuf, value].join('\n')) + // eslint-disable-next-line react-hooks/exhaustive-deps }, - // eslint-disable-next-line react-hooks/exhaustive-deps [dequeue, dispatchSubmission, inputBuf, sid] ) + // ── Derived ────────────────────────────────────────────────────── + const statusColor = status === 'ready' ? theme.color.ok @@ -1456,6 +1536,8 @@ export function App({ gw }: { gw: GatewayClient }) { ? theme.color.warn : theme.color.dim + // ── Render ─────────────────────────────────────────────────────── + return ( @@ -1483,26 +1565,27 @@ export function App({ gw }: { gw: GatewayClient }) { {streaming && ( - - - + + )} + + {(thinking || tools.length > 0) && (!streaming || tools.length > 0) && ( + )} - {(thinking || tools.length > 0) && (!streaming || tools.length > 0) && } {pasteReview && ( - + Review large paste before send pastes: {pasteReview.largeIds.map(id => `#${id}`).join(', ')} Enter to send · Esc/Ctrl+C to cancel - + )} {clarify && ( - + { gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) @@ -1512,11 +1595,11 @@ export function App({ gw }: { gw: GatewayClient }) { req={clarify} t={theme} /> - + )} {approval && ( - + { gw.request('approval.respond', { choice, session_id: sid }).catch(() => {}) @@ -1527,42 +1610,42 @@ export function App({ gw }: { gw: GatewayClient }) { req={approval} t={theme} /> - + )} {sudo && ( - + { - gw.request('sudo.respond', { request_id: sudo.requestId, password }).catch(() => {}) + onSubmit={pw => { + gw.request('sudo.respond', { request_id: sudo.requestId, password: pw }).catch(() => {}) setSudo(null) setStatus('running…') }} t={theme} /> - + )} {secret && ( - + { - gw.request('secret.respond', { request_id: secret.requestId, value }).catch(() => {}) + onSubmit={val => { + gw.request('secret.respond', { request_id: secret.requestId, value: val }).catch(() => {}) setSecret(null) setStatus('running…') }} sub={`for ${secret.envVar}`} t={theme} /> - + )} {picker && ( - + setPicker(false)} @@ -1571,21 +1654,14 @@ export function App({ gw }: { gw: GatewayClient }) { setStatus('resuming…') gw.request('session.resume', { cols, session_id: id }) .then((r: any) => { + resetSession() setSid(r.session_id) - setHistoryItems([]) - setMessages([]) setInfo(r.info ?? null) - setPastes([]) - setActivity([]) - setIntroCollapsed(false) if (r.info) { appendHistory(introMsg(r.info)) } - setUsage(ZERO) - lastStatusNoteRef.current = '' - protocolWarnedRef.current = false sys(`resumed session (${r.message_count} messages)`) setStatus('ready') }) @@ -1596,11 +1672,17 @@ export function App({ gw }: { gw: GatewayClient }) { }} t={theme} /> - + )} + {bgTasks.size > 0 && ( + + {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running · /stop to cancel + + )} + {statusBar && ( @@ -1608,12 +1690,18 @@ export function App({ gw }: { gw: GatewayClient }) { color={theme.color.bronze} cols={cols} dimColor={theme.color.dim} - parts={[status, sid, info?.model?.split('/').pop(), usage.total > 0 && `${fmtK(usage.total)} tok`]} + parts={[ + status, + sid, + info?.model?.split('/').pop(), + bgTasks.size > 0 && `${bgTasks.size} bg`, + usage.total > 0 && `${fmtK(usage.total)} tok` + ]} statusColor={statusColor} /> )} - {!blocked && ( + {!isBlocked && ( diff --git a/ui-tui/src/components/activityLane.tsx b/ui-tui/src/components/activityLane.tsx index c7febfd4a0..527a9641c0 100644 --- a/ui-tui/src/components/activityLane.tsx +++ b/ui-tui/src/components/activityLane.tsx @@ -3,24 +3,21 @@ import { Box, Text } from 'ink' import type { Theme } from '../theme.js' import type { ActivityItem } from '../types.js' +const toneColor = (item: ActivityItem, t: Theme) => + item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim + export function ActivityLane({ items, t }: { items: ActivityItem[]; t: Theme }) { if (!items.length) { return null } - const visible = items.slice(-4) - return ( - {visible.map(item => { - const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim - - return ( - - {t.brand.tool} {item.text} - - ) - })} + {items.slice(-4).map(item => ( + + {t.brand.tool} {item.text} + + ))} ) } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 676e10cb4b..cdec6f3e7a 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -20,7 +20,6 @@ export const MessageLine = memo(function MessageLine({ t: Theme }) { const { body, glyph, prefix } = ROLE[msg.role](t) - const contentWidth = Math.max(20, cols - 5) if (msg.role === 'tool') { return ( @@ -61,7 +60,7 @@ export const MessageLine = memo(function MessageLine({ - {content} + {content} ) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9e7e5ab10c..38d45358cf 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1,7 +1,7 @@ import { Text, useInput } from 'ink' import { useEffect, useRef, useState } from 'react' -function wl(s: string, p: number) { +function wordLeft(s: string, p: number) { let i = p - 1 while (i > 0 && /\s/.test(s[i]!)) { @@ -15,7 +15,7 @@ function wl(s: string, p: number) { return Math.max(0, i) } -function wr(s: string, p: number) { +function wordRight(s: string, p: number) { let i = p while (i < s.length && !/\s/.test(s[i]!)) { @@ -29,7 +29,7 @@ function wr(s: string, p: number) { return i } -const ESC = String.fromCharCode(0x1b) +const ESC = '\x1b' const INV = ESC + '[7m' const INV_OFF = ESC + '[27m' const DIM = ESC + '[2m' @@ -63,6 +63,16 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } }, [value]) + const commit = (v: string, c: number) => { + c = Math.max(0, Math.min(c, v.length)) + setCur(c) + + if (v !== value) { + selfChange.current = true + onChange(v) + } + } + const flushPaste = () => { const pasted = pasteBuf.current const at = pastePos.current @@ -85,9 +95,8 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } if (pasted.length && PRINTABLE.test(pasted)) { - const nv = v.slice(0, at) + pasted + v.slice(at) selfChange.current = true - onChange(nv) + onChange(v.slice(0, at) + pasted + v.slice(at)) setCur(at + pasted.length) } } @@ -113,9 +122,8 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } - let c = cur, - v = value - + let c = cur + let v = value const mod = k.ctrl || k.meta if (k.home || (k.ctrl && inp === 'a')) { @@ -123,12 +131,12 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } else if (k.end || (k.ctrl && inp === 'e')) { c = v.length } else if (k.leftArrow) { - c = mod ? wl(v, c) : Math.max(0, c - 1) + c = mod ? wordLeft(v, c) : Math.max(0, c - 1) } else if (k.rightArrow) { - c = mod ? wr(v, c) : Math.min(v.length, c + 1) + c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) } else if ((k.backspace || k.delete) && c > 0) { if (mod) { - const t = wl(v, c) + const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) c = t } else { @@ -136,7 +144,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' c-- } } else if (k.ctrl && inp === 'w' && c > 0) { - const t = wl(v, c) + const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) c = t } else if (k.ctrl && inp === 'u') { @@ -145,9 +153,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } else if (k.ctrl && inp === 'k') { v = v.slice(0, c) } else if (k.meta && inp === 'b') { - c = wl(v, c) + c = wordLeft(v, c) } else if (k.meta && inp === 'f') { - c = wr(v, c) + c = wordRight(v, c) } else if (inp.length > 0) { const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') @@ -155,9 +163,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } - const isMultiChar = raw.length > 1 || raw.includes('\n') - - if (isMultiChar) { + if (raw.length > 1 || raw.includes('\n')) { if (!pasteBuf.current) { pastePos.current = c } @@ -183,13 +189,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } - c = Math.max(0, Math.min(c, v.length)) - setCur(c) - - if (v !== value) { - selfChange.current = true - onChange(v) - } + commit(v, c) }, { isActive: focus } ) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a9f4ceede6..a813765bb0 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -9,19 +9,19 @@ import type { ActiveTool } from '../types.js' const THINK_POOL: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL_POOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] -const pick = (arr: T[]) => arr[Math.floor(Math.random() * arr.length)]! +const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL_POOL : THINK_POOL)]) - const [i, setI] = useState(0) + const [frame, setFrame] = useState(0) useEffect(() => { - const id = setInterval(() => setI(p => (p + 1) % spin.frames.length), spin.interval) + const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) return () => clearInterval(id) }, [spin]) - return {spin.frames[i]} + return {spin.frames[frame]} } export const Thinking = memo(function Thinking({ @@ -36,9 +36,7 @@ export const Thinking = memo(function Thinking({ const [tick, setTick] = useState(0) useEffect(() => { - const id = setInterval(() => { - setTick(v => v + 1) - }, 1100) + const id = setInterval(() => setTick(v => v + 1), 1100) return () => clearInterval(id) }, []) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index f72ce34bfc..63e5f8da3f 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -39,9 +39,7 @@ export const HOTKEYS: [string, string][] = [ ] export const INTERPOLATION_RE = /\{!(.+?)\}/g - export const LONG_MSG = 300 -export const MAX_CTX = 128_000 export const PLACEHOLDERS = [ 'Ask me anything…', diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 37f1d5bde7..3ae7ada19e 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -62,7 +62,7 @@ export const DEFAULT_THEME: Theme = { diffAdded: 'rgb(220,255,220)', diffRemoved: 'rgb(255,220,220)', diffAddedWord: 'rgb(36,138,61)', - diffRemovedWord: 'rgb(207,34,46)', + diffRemovedWord: 'rgb(207,34,46)' }, brand: { @@ -110,7 +110,7 @@ export function fromSkin( diffAdded: d.color.diffAdded, diffRemoved: d.color.diffRemoved, diffAddedWord: d.color.diffAddedWord, - diffRemovedWord: d.color.diffRemovedWord, + diffRemovedWord: d.color.diffRemovedWord }, brand: { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 7d287dc38b..5c6f0a76ac 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -24,9 +24,8 @@ export interface ClarifyReq { export interface Msg { role: Role text: string - kind?: 'intro' | 'tool-active' + kind?: 'intro' info?: SessionInfo - toolId?: string } export type Role = 'assistant' | 'system' | 'tool' | 'user' @@ -52,7 +51,6 @@ export interface Usage { export interface SudoReq { requestId: string } - export interface SecretReq { envVar: string prompt: string @@ -72,7 +70,6 @@ export interface PendingPaste { text: string } -/** From `commands.catalog` — mirrors hermes_cli.commands COMMANDS + SUBCOMMANDS + skills. */ export interface SlashCatalog { canon: Record pairs: [string, string][] From a435c7274a0dfe98941bfb666f53d10bb40e9c17 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 14:22:36 -0500 Subject: [PATCH 031/157] chore: uptick --- ui-tui/src/app.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 94144b9aad..2fed081e2a 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1314,9 +1314,15 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'personality': - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => - sys(`personality: ${r.value || 'default'}`) - ) + if (arg) { + rpc('config.set', { key: 'personality', value: arg }).then((r: any) => + sys(`personality: ${r.value || 'default'}`) + ) + } else { + gw.request('slash.exec', { command: 'personality', session_id: sid }) + .then((r: any) => sys(r?.output || '(no output)')) + .catch(() => sys('personality command failed')) + } return true From 9d8f9765c1cf236117c3862e893c2bf5ba12b347 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 19:31:25 -0500 Subject: [PATCH 032/157] feat: add tests and update mds --- AGENTS.md | 64 + tests/tui_gateway/__init__.py | 0 tests/tui_gateway/test_protocol.py | 187 + tests/tui_gateway/test_render.py | 67 + ui-tui/README.md | 3 +- ui-tui/package-lock.json | 5457 ++++++++++++++++++++++++ ui-tui/package.json | 7 +- ui-tui/src/__tests__/constants.test.ts | 43 + ui-tui/src/__tests__/messages.test.ts | 25 + ui-tui/src/__tests__/text.test.ts | 112 + ui-tui/src/__tests__/theme.test.ts | 52 + 11 files changed, 6013 insertions(+), 4 deletions(-) create mode 100644 tests/tui_gateway/__init__.py create mode 100644 tests/tui_gateway/test_protocol.py create mode 100644 tests/tui_gateway/test_render.py create mode 100644 ui-tui/package-lock.json create mode 100644 ui-tui/src/__tests__/constants.test.ts create mode 100644 ui-tui/src/__tests__/messages.test.ts create mode 100644 ui-tui/src/__tests__/text.test.ts create mode 100644 ui-tui/src/__tests__/theme.test.ts diff --git a/AGENTS.md b/AGENTS.md index 8045c3d213..4a668caacf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,18 @@ hermes-agent/ │ ├── run.py # Main loop, slash commands, message dispatch │ ├── session.py # SessionStore — conversation persistence │ └── platforms/ # Adapters: telegram, discord, slack, whatsapp, homeassistant, signal +├── ui-tui/ # Ink (React) terminal UI — `hermes --tui` +│ ├── src/entry.tsx # TTY gate + render() +│ ├── src/app.tsx # Main state machine and UI +│ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge +│ ├── src/components/ # Ink components (branding, markdown, prompts, etc.) +│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue +│ └── src/lib/ # Pure helpers (history, osc52, text) +├── tui_gateway/ # Python JSON-RPC backend for Ink TUI +│ ├── entry.py # stdio entrypoint +│ ├── server.py # RPC handlers and session logic +│ ├── render.py # Optional rich/ANSI bridge +│ └── slash_worker.py # Persistent HermesCLI subprocess for slash commands ├── acp_adapter/ # ACP server (VS Code / Zed / JetBrains integration) ├── cron/ # Scheduler (jobs.py, scheduler.py) ├── environments/ # RL training environments (Atropos) @@ -179,6 +191,58 @@ if canonical == "mycommand": --- +## TUI Architecture (ui-tui + tui_gateway) + +The Ink TUI is a full replacement for the PT CLI, activated via `hermes --tui` or `HERMES_TUI=1`. + +### Process Model + +``` +hermes --tui + └─ Node (Ink) ──stdio JSON-RPC── Python (tui_gateway) + │ └─ AIAgent + tools + sessions + └─ renders transcript, composer, prompts, activity +``` + +TypeScript owns the screen. Python owns sessions, tools, model calls, and slash command logic. + +### Transport + +Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. See `tui_gateway/server.py` for the full method/event catalog. + +### Key Surfaces + +| Surface | Ink component | Gateway method | +|---------|---------------|----------------| +| Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` | +| Tool activity | `activityLane.tsx` | `tool.start/progress/complete` | +| Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` | +| Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` | +| Session picker | `sessionPicker.tsx` | `session.list/resume` | +| Slash commands | Local handler + fallthrough | `slash.exec` → `_SlashWorker`, `command.dispatch` | +| Completions | `useCompletion` hook | `complete.slash`, `complete.path` | +| Theming | `theme.ts` + `branding.tsx` | `gateway.ready` with skin data | + +### Slash Command Flow + +1. Built-in client commands (`/help`, `/quit`, `/clear`, `/resume`, `/copy`, `/paste`, etc.) handled locally in `app.tsx` +2. Everything else → `slash.exec` (runs in persistent `_SlashWorker` subprocess) → `command.dispatch` fallback + +### Dev Commands + +```bash +cd ui-tui +npm install # first time +npm run dev # watch mode +npm start # production +npm run build # typecheck +npm run lint # eslint +npm run fmt # prettier +npm test # vitest +``` + +--- + ## Adding New Tools Requires changes in **3 files**: diff --git a/tests/tui_gateway/__init__.py b/tests/tui_gateway/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py new file mode 100644 index 0000000000..7e9d519ee8 --- /dev/null +++ b/tests/tui_gateway/test_protocol.py @@ -0,0 +1,187 @@ +"""Tests for tui_gateway JSON-RPC protocol plumbing.""" + +import io +import json +import sys +import threading +from unittest.mock import MagicMock, patch + +import pytest + +_original_stdout = sys.stdout + + +@pytest.fixture(autouse=True) +def _restore_stdout(): + yield + sys.stdout = _original_stdout + + +@pytest.fixture() +def server(): + with patch.dict("sys.modules", { + "hermes_constants": MagicMock(get_hermes_home=MagicMock(return_value="/tmp/hermes_test")), + "hermes_cli.env_loader": MagicMock(), + "hermes_cli.banner": MagicMock(), + "hermes_state": MagicMock(), + }): + import importlib + mod = importlib.import_module("tui_gateway.server") + yield mod + mod._sessions.clear() + mod._pending.clear() + mod._answers.clear() + mod._methods.clear() + importlib.reload(mod) + + +@pytest.fixture() +def capture(server): + """Redirect server's real stdout to a StringIO and return (server, buf).""" + buf = io.StringIO() + server._real_stdout = buf + return server, buf + + +# ── JSON-RPC envelope ──────────────────────────────────────────────── + + +def test_unknown_method(server): + resp = server.handle_request({"id": "1", "method": "bogus"}) + assert resp["error"]["code"] == -32601 + + +def test_ok_envelope(server): + assert server._ok("r1", {"x": 1}) == { + "jsonrpc": "2.0", "id": "r1", "result": {"x": 1}, + } + + +def test_err_envelope(server): + assert server._err("r2", 4001, "nope") == { + "jsonrpc": "2.0", "id": "r2", "error": {"code": 4001, "message": "nope"}, + } + + +# ── write_json ─────────────────────────────────────────────────────── + + +def test_write_json(capture): + server, buf = capture + assert server.write_json({"test": True}) + assert json.loads(buf.getvalue()) == {"test": True} + + +def test_write_json_broken_pipe(server): + class _Broken: + def write(self, _): raise BrokenPipeError + def flush(self): raise BrokenPipeError + + server._real_stdout = _Broken() + assert server.write_json({"x": 1}) is False + + +# ── _emit ──────────────────────────────────────────────────────────── + + +def test_emit_with_payload(capture): + server, buf = capture + server._emit("test.event", "s1", {"key": "val"}) + msg = json.loads(buf.getvalue()) + + assert msg["method"] == "event" + assert msg["params"]["type"] == "test.event" + assert msg["params"]["session_id"] == "s1" + assert msg["params"]["payload"]["key"] == "val" + + +def test_emit_without_payload(capture): + server, buf = capture + server._emit("ping", "s2") + + assert "payload" not in json.loads(buf.getvalue())["params"] + + +# ── Blocking prompt round-trip ─────────────────────────────────────── + + +def test_block_and_respond(capture): + server, _ = capture + result = [None] + + threading.Thread( + target=lambda: result.__setitem__(0, server._block("test.prompt", "s1", {"q": "?"}, timeout=5)), + ).start() + + for _ in range(100): + if server._pending: + break + threading.Event().wait(0.01) + + rid = next(iter(server._pending)) + server._answers[rid] = "my_answer" + server._pending[rid].set() + + threading.Event().wait(0.1) + assert result[0] == "my_answer" + + +def test_clear_pending(server): + ev = threading.Event() + server._pending["r1"] = ev + server._clear_pending() + + assert ev.is_set() + assert server._answers["r1"] == "" + + +# ── Session lookup ─────────────────────────────────────────────────── + + +def test_sess_missing(server): + _, err = server._sess({"session_id": "nope"}, "r1") + assert err["error"]["code"] == 4001 + + +def test_sess_found(server): + server._sessions["abc"] = {"agent": MagicMock()} + s, err = server._sess({"session_id": "abc"}, "r1") + + assert s is not None + assert err is None + + +# ── Config I/O ─────────────────────────────────────────────────────── + + +def test_config_load_missing(server, tmp_path): + server._hermes_home = tmp_path + assert server._load_cfg() == {} + + +def test_config_roundtrip(server, tmp_path): + server._hermes_home = tmp_path + server._save_cfg({"model": "test/model"}) + assert server._load_cfg()["model"] == "test/model" + + +# ── _cli_exec_blocked ──────────────────────────────────────────────── + + +@pytest.mark.parametrize("argv", [ + [], + ["setup"], + ["gateway"], + ["sessions", "browse"], + ["config", "edit"], +]) +def test_cli_exec_blocked(server, argv): + assert server._cli_exec_blocked(argv) is not None + + +@pytest.mark.parametrize("argv", [ + ["version"], + ["sessions", "list"], +]) +def test_cli_exec_allowed(server, argv): + assert server._cli_exec_blocked(argv) is None diff --git a/tests/tui_gateway/test_render.py b/tests/tui_gateway/test_render.py new file mode 100644 index 0000000000..3054846b88 --- /dev/null +++ b/tests/tui_gateway/test_render.py @@ -0,0 +1,67 @@ +"""Tests for tui_gateway.render — rendering bridge fallback behavior.""" + +from unittest.mock import MagicMock, patch + +from tui_gateway.render import make_stream_renderer, render_diff, render_message + + +def _stub_rich(mock_mod): + return patch.dict("sys.modules", {"agent.rich_output": mock_mod}) + + +def _no_rich(): + return patch.dict("sys.modules", {"agent.rich_output": None}) + + +# ── render_message ─────────────────────────────────────────────────── + + +def test_render_message_none_without_module(): + with _no_rich(): + assert render_message("hello") is None + + +def test_render_message_formatted(): + mod = MagicMock() + mod.format_response.return_value = "hi" + + with _stub_rich(mod): + assert render_message("hi", 100) == "hi" + + +def test_render_message_type_error_fallback(): + mod = MagicMock() + mod.format_response.side_effect = [TypeError, "fallback"] + + with _stub_rich(mod): + assert render_message("hi") == "fallback" + + +def test_render_message_exception_returns_none(): + mod = MagicMock() + mod.format_response.side_effect = RuntimeError + + with _stub_rich(mod): + assert render_message("hi") is None + + +# ── render_diff / make_stream_renderer ─────────────────────────────── + + +def test_render_diff_none_without_module(): + with _no_rich(): + assert render_diff("+line") is None + + +def test_stream_renderer_none_without_module(): + with _no_rich(): + assert make_stream_renderer() is None + + +def test_stream_renderer_returns_instance(): + renderer = MagicMock() + mod = MagicMock() + mod.StreamingRenderer.return_value = renderer + + with _stub_rich(mod): + assert make_stream_renderer(120) is renderer diff --git a/ui-tui/README.md b/ui-tui/README.md index 89719a43b9..5ff56e6176 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -294,5 +294,4 @@ tui_gateway/ ## Notes -- `src/main.tsx` currently duplicates `entry.tsx`. -- `src/altScreen.tsx`, `components/commandPalette.tsx`, and `lib/slash.ts` exist, but are not part of the active runtime path from `entry.tsx` to `app.tsx`. +- No dead code: `main.tsx`, `altScreen.tsx`, `commandPalette.tsx`, and `lib/slash.ts` have been removed. diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json new file mode 100644 index 0000000000..03c9c33976 --- /dev/null +++ b/ui-tui/package-lock.json @@ -0,0 +1,5457 @@ +{ + "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", + "unicode-animations": "^1.0.3" + }, + "devDependencies": { + "@eslint/js": "^9", + "@types/node": "^25.5.0", + "@types/react": "^19.2.14", + "@typescript-eslint/eslint-plugin": "^8", + "@typescript-eslint/parser": "^8", + "eslint": "^9", + "eslint-plugin-perfectionist": "^5", + "eslint-plugin-react": "^7", + "eslint-plugin-react-hooks": "^7", + "eslint-plugin-unused-imports": "^4", + "globals": "^16", + "prettier": "^3", + "tsx": "^4.19.0", + "typescript": "^5.7.0", + "vitest": "^4.1.3" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.2.5", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.3", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.3", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.3", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "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", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001784", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "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", + "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", + "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", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "2.1.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.331", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-abstract": { + "version": "1.24.1", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.1", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", + "safe-array-concat": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.5", + "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/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.58.0", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.3", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.2.1", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.9", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.1", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.12", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-unused-imports": { + "version": "4.4.1", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", + "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/function-bind": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "6.8.0", + "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", + "license": "MIT", + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink-text-input/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink-text-input/node_modules/type-fest": { + "version": "4.41.0", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ink/node_modules/string-width": { + "version": "8.2.0", + "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", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "license": "MIT", + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "get-proto": "^1.0.0", + "has-symbols": "^1.1.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/onetime": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "dev": true, + "license": "MIT" + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "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/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.6", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.6", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "internal-slot": "^1.1.0", + "regexp.prototype.flags": "^1.5.3", + "set-function-name": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.21.0", + "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-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-animations": { + "version": "1.0.3", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "unicode-animations": "^1.0.1" + }, + "bin": { + "unicode-animations": "scripts/demo.cjs" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "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", + "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/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "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/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "license": "MIT" + }, + "node_modules/zod": { + "version": "4.3.6", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/ui-tui/package.json b/ui-tui/package.json index ccf17b20b7..5100edbd21 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -10,7 +10,9 @@ "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}'", - "fix": "npm run lint:fix && npm run fmt" + "fix": "npm run lint:fix && npm run fmt", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "ink": "^6.8.0", @@ -32,6 +34,7 @@ "globals": "^16", "prettier": "^3", "tsx": "^4.19.0", - "typescript": "^5.7.0" + "typescript": "^5.7.0", + "vitest": "^4.1.3" } } diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts new file mode 100644 index 0000000000..0a864f160b --- /dev/null +++ b/ui-tui/src/__tests__/constants.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { FACES, HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ROLE, TOOL_VERBS, VERBS, ZERO } from '../constants.js' +import { DEFAULT_THEME } from '../theme.js' + + +describe('constants', () => { + + it('ZERO', () => expect(ZERO).toEqual({ calls: 0, input: 0, output: 0, total: 0 })) + + it('string arrays are populated', () => { + for (const arr of [FACES, PLACEHOLDERS, VERBS]) { + expect(arr.length).toBeGreaterThan(0) + arr.forEach(s => expect(typeof s).toBe('string')) + } + }) + + it('HOTKEYS are [key, desc] pairs', () => { + HOTKEYS.forEach(([k, d]) => { + expect(typeof k).toBe('string') + expect(typeof d).toBe('string') + }) + }) + + it('TOOL_VERBS maps known tools', () => { + expect(TOOL_VERBS.terminal).toContain('terminal') + expect(TOOL_VERBS.read_file).toContain('reading') + }) + + it('INTERPOLATION_RE matches {!cmd}', () => { + INTERPOLATION_RE.lastIndex = 0 + expect(INTERPOLATION_RE.test('{!date}')).toBe(true) + + INTERPOLATION_RE.lastIndex = 0 + expect(INTERPOLATION_RE.test('plain')).toBe(false) + }) + + it('ROLE produces glyph/body/prefix per role', () => { + for (const role of ['assistant', 'system', 'tool', 'user'] as const) { + expect(ROLE[role](DEFAULT_THEME)).toHaveProperty('glyph') + } + }) +}) diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts new file mode 100644 index 0000000000..0ce7150553 --- /dev/null +++ b/ui-tui/src/__tests__/messages.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { upsert } from '../lib/messages.js' + + +describe('upsert', () => { + + it('appends when last role differs', () => { + expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2) + }) + + it('replaces when last role matches', () => { + expect(upsert([{ role: 'assistant', text: 'partial' }], 'assistant', 'full')[0]!.text).toBe('full') + }) + + it('appends to empty', () => { + expect(upsert([], 'user', 'first')).toEqual([{ role: 'user', text: 'first' }]) + }) + + it('does not mutate', () => { + const prev = [{ role: 'user' as const, text: 'hi' }] + upsert(prev, 'assistant', 'yo') + expect(prev).toHaveLength(1) + }) +}) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts new file mode 100644 index 0000000000..322d292942 --- /dev/null +++ b/ui-tui/src/__tests__/text.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' + +import { + compactPreview, + estimateRows, + fmtK, + hasAnsi, + hasInterpolation, + pick, + stripAnsi, + userDisplay +} from '../lib/text.js' + + +describe('stripAnsi / hasAnsi', () => { + + it('strips ANSI codes', () => { + expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red') + }) + + it('passes plain text through', () => { + expect(stripAnsi('hello')).toBe('hello') + }) + + it('detects ANSI', () => { + expect(hasAnsi('\x1b[1mbold\x1b[0m')).toBe(true) + expect(hasAnsi('plain')).toBe(false) + }) +}) + + +describe('compactPreview', () => { + + it('truncates with ellipsis', () => { + expect(compactPreview('a'.repeat(100), 20)).toHaveLength(20) + expect(compactPreview('a'.repeat(100), 20).at(-1)).toBe('…') + }) + + it('returns short strings as-is', () => { + expect(compactPreview('hello', 20)).toBe('hello') + }) + + it('collapses whitespace', () => { + expect(compactPreview(' a b ', 20)).toBe('a b') + }) + + it('returns empty for whitespace-only', () => { + expect(compactPreview(' ', 20)).toBe('') + }) +}) + + +describe('estimateRows', () => { + + it('single line', () => expect(estimateRows('hello', 80)).toBe(1)) + + it('wraps long lines', () => expect(estimateRows('a'.repeat(160), 80)).toBe(2)) + + it('counts newlines', () => expect(estimateRows('a\nb\nc', 80)).toBe(3)) + + it('skips table separators', () => { + expect(estimateRows('| a | b |\n|---|---|\n| 1 | 2 |', 80)).toBe(2) + }) + + it('handles code blocks', () => { + expect(estimateRows('```python\nprint("hi")\n```', 80)).toBeGreaterThanOrEqual(2) + }) + + it('compact mode skips empty lines', () => { + expect(estimateRows('a\n\nb', 80, true)).toBe(2) + expect(estimateRows('a\n\nb', 80, false)).toBe(3) + }) +}) + + +describe('fmtK', () => { + + it('formats thousands', () => expect(fmtK(1500)).toBe('1.5k')) + + it('keeps small numbers', () => expect(fmtK(42)).toBe('42')) + + it('boundary', () => { + expect(fmtK(1000)).toBe('1.0k') + expect(fmtK(999)).toBe('999') + }) +}) + + +describe('hasInterpolation', () => { + + it('detects {!cmd}', () => expect(hasInterpolation('echo {!date}')).toBe(true)) + + it('rejects plain text', () => expect(hasInterpolation('plain')).toBe(false)) +}) + + +describe('pick', () => { + + it('returns element from array', () => { + expect([1, 2, 3]).toContain(pick([1, 2, 3])) + }) +}) + + +describe('userDisplay', () => { + + it('returns short messages as-is', () => expect(userDisplay('hello')).toBe('hello')) + + it('truncates long messages', () => { + expect(userDisplay('word '.repeat(100))).toContain('[long message]') + }) +}) diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts new file mode 100644 index 0000000000..ea0011a519 --- /dev/null +++ b/ui-tui/src/__tests__/theme.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_THEME, fromSkin } from '../theme.js' + + +describe('DEFAULT_THEME', () => { + + it('has brand defaults', () => { + expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent') + expect(DEFAULT_THEME.brand.prompt).toBe('❯') + expect(DEFAULT_THEME.brand.tool).toBe('┊') + }) + + it('has color palette', () => { + expect(DEFAULT_THEME.color.gold).toBe('#FFD700') + expect(DEFAULT_THEME.color.error).toBe('#ef5350') + }) +}) + + +describe('fromSkin', () => { + + it('overrides banner colors', () => { + expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000') + }) + + it('preserves unset colors', () => { + expect(fromSkin({ banner_title: '#FF0000' }, {}).color.amber).toBe(DEFAULT_THEME.color.amber) + }) + + it('overrides branding', () => { + const { brand } = fromSkin({}, { agent_name: 'TestBot', prompt_symbol: '$' }) + expect(brand.name).toBe('TestBot') + expect(brand.prompt).toBe('$') + }) + + it('defaults for empty skin', () => { + expect(fromSkin({}, {}).color).toEqual(DEFAULT_THEME.color) + expect(fromSkin({}, {}).brand.icon).toBe(DEFAULT_THEME.brand.icon) + }) + + it('passes banner logo/hero', () => { + expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerLogo).toBe('LOGO') + expect(fromSkin({}, {}, 'LOGO', 'HERO').bannerHero).toBe('HERO') + }) + + it('maps ui_ color keys + cascades to status', () => { + const { color } = fromSkin({ ui_ok: '#008000' }, {}) + expect(color.ok).toBe('#008000') + expect(color.statusGood).toBe('#008000') + }) +}) From c49bbbe8c2b2db76939d764b855797b629623f35 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 22:02:38 -0500 Subject: [PATCH 033/157] chore: fmt --- ui-tui/src/__tests__/constants.test.ts | 2 -- ui-tui/src/__tests__/messages.test.ts | 2 -- ui-tui/src/__tests__/text.test.ts | 14 -------------- ui-tui/src/__tests__/theme.test.ts | 4 ---- ui-tui/src/app.tsx | 5 ++--- 5 files changed, 2 insertions(+), 25 deletions(-) diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts index 0a864f160b..c469883079 100644 --- a/ui-tui/src/__tests__/constants.test.ts +++ b/ui-tui/src/__tests__/constants.test.ts @@ -3,9 +3,7 @@ import { describe, expect, it } from 'vitest' import { FACES, HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ROLE, TOOL_VERBS, VERBS, ZERO } from '../constants.js' import { DEFAULT_THEME } from '../theme.js' - describe('constants', () => { - it('ZERO', () => expect(ZERO).toEqual({ calls: 0, input: 0, output: 0, total: 0 })) it('string arrays are populated', () => { diff --git a/ui-tui/src/__tests__/messages.test.ts b/ui-tui/src/__tests__/messages.test.ts index 0ce7150553..8f6a265f1d 100644 --- a/ui-tui/src/__tests__/messages.test.ts +++ b/ui-tui/src/__tests__/messages.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it } from 'vitest' import { upsert } from '../lib/messages.js' - describe('upsert', () => { - it('appends when last role differs', () => { expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2) }) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 322d292942..be93126c72 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -11,9 +11,7 @@ import { userDisplay } from '../lib/text.js' - describe('stripAnsi / hasAnsi', () => { - it('strips ANSI codes', () => { expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red') }) @@ -28,9 +26,7 @@ describe('stripAnsi / hasAnsi', () => { }) }) - describe('compactPreview', () => { - it('truncates with ellipsis', () => { expect(compactPreview('a'.repeat(100), 20)).toHaveLength(20) expect(compactPreview('a'.repeat(100), 20).at(-1)).toBe('…') @@ -49,9 +45,7 @@ describe('compactPreview', () => { }) }) - describe('estimateRows', () => { - it('single line', () => expect(estimateRows('hello', 80)).toBe(1)) it('wraps long lines', () => expect(estimateRows('a'.repeat(160), 80)).toBe(2)) @@ -72,9 +66,7 @@ describe('estimateRows', () => { }) }) - describe('fmtK', () => { - it('formats thousands', () => expect(fmtK(1500)).toBe('1.5k')) it('keeps small numbers', () => expect(fmtK(42)).toBe('42')) @@ -85,25 +77,19 @@ describe('fmtK', () => { }) }) - describe('hasInterpolation', () => { - it('detects {!cmd}', () => expect(hasInterpolation('echo {!date}')).toBe(true)) it('rejects plain text', () => expect(hasInterpolation('plain')).toBe(false)) }) - describe('pick', () => { - it('returns element from array', () => { expect([1, 2, 3]).toContain(pick([1, 2, 3])) }) }) - describe('userDisplay', () => { - it('returns short messages as-is', () => expect(userDisplay('hello')).toBe('hello')) it('truncates long messages', () => { diff --git a/ui-tui/src/__tests__/theme.test.ts b/ui-tui/src/__tests__/theme.test.ts index ea0011a519..86a9768b0f 100644 --- a/ui-tui/src/__tests__/theme.test.ts +++ b/ui-tui/src/__tests__/theme.test.ts @@ -2,9 +2,7 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_THEME, fromSkin } from '../theme.js' - describe('DEFAULT_THEME', () => { - it('has brand defaults', () => { expect(DEFAULT_THEME.brand.name).toBe('Hermes Agent') expect(DEFAULT_THEME.brand.prompt).toBe('❯') @@ -17,9 +15,7 @@ describe('DEFAULT_THEME', () => { }) }) - describe('fromSkin', () => { - it('overrides banner colors', () => { expect(fromSkin({ banner_title: '#FF0000' }, {}).color.gold).toBe('#FF0000') }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 2fed081e2a..93fc5159c2 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -213,6 +213,7 @@ export function App({ gw }: { gw: GatewayClient }) { const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = useQueue() + const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) @@ -733,6 +734,7 @@ export function App({ gw }: { gw: GatewayClient }) { queueEditIdx === null ? queueRef.current.length - 1 : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length + setQueueEdit(idx) setHistoryIdx(null) setInput(queueRef.current[idx] ?? '') @@ -1014,7 +1016,6 @@ export function App({ gw }: { gw: GatewayClient }) { break } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [appendMessage, dequeue, newSession, pushActivity, send, sys] ) @@ -1470,7 +1471,6 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [catalog, compact, gw, lastUserMsg, messages, newSession, pastes, pushActivity, rpc, send, sid, statusBar, sys] ) @@ -1526,7 +1526,6 @@ export function App({ gw }: { gw: GatewayClient }) { } dispatchSubmission([...inputBuf, value].join('\n')) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [dequeue, dispatchSubmission, inputBuf, sid] ) From b66550ed08bdbf6847a67618bb0d5fee0371fb6b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 8 Apr 2026 23:59:56 -0500 Subject: [PATCH 034/157] fix(tui): stabilize multiline input, persist tool traces, and port CLI-style context status bar --- hermes_cli/main.py | 13 +- tui_gateway/server.py | 12 +- ui-tui/README.md | 122 ++++++++-------- ui-tui/src/app.tsx | 200 ++++++++++++++++++-------- ui-tui/src/components/messageLine.tsx | 20 +++ ui-tui/src/components/textInput.tsx | 12 +- ui-tui/src/components/thinking.tsx | 3 +- ui-tui/src/constants.ts | 4 +- ui-tui/src/types.ts | 5 + 9 files changed, 262 insertions(+), 129 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d4793db590..e5ddf497a6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -565,11 +565,18 @@ def _launch_tui(): sys.exit(1) print("Installing TUI dependencies…") result = subprocess.run( - [npm, "install", "--silent"], - cwd=str(tui_dir), capture_output=True, text=True, + [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], + cwd=str(tui_dir), + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, ) if result.returncode != 0: - print(f"npm install failed:\n{result.stderr}") + err = (result.stderr or "").strip() + preview = "\n".join(err.splitlines()[-30:]) + print("npm install failed.") + if preview: + print(preview) sys.exit(1) tsx = tui_dir / "node_modules" / ".bin" / "tsx" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index dd375b836e..654c9e9e39 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -230,12 +230,21 @@ def _resolve_model() -> str: def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) - return { + usage = { "input": g("session_input_tokens", "session_prompt_tokens"), "output": g("session_output_tokens", "session_completion_tokens"), "total": g("session_total_tokens"), "calls": g("session_api_calls"), } + comp = getattr(agent, "context_compressor", None) + if comp: + ctx_used = getattr(comp, "last_prompt_tokens", 0) or usage["total"] or 0 + ctx_max = getattr(comp, "context_length", 0) or 0 + if ctx_max: + usage["context_used"] = ctx_used + usage["context_max"] = ctx_max + usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) + return usage def _session_info(agent) -> dict: @@ -248,6 +257,7 @@ def _session_info(agent) -> dict: "release_date": "", "update_behind": None, "update_command": "", + "usage": _get_usage(agent), } try: from hermes_cli import __version__, __release_date__ diff --git a/ui-tui/README.md b/ui-tui/README.md index 5ff56e6176..8783b18fbd 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -84,31 +84,31 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an ### Main chat input -| Key | Behavior | -|---|---| -| `Enter` | Submit the current draft | -| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message | -| `\` + `Enter` | Append the line to the multiline buffer instead of sending | -| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending | -| `Ctrl+D` | Exit | -| `Ctrl+G` | Open `$EDITOR` with the current draft | -| `Ctrl+L` | New session (same as `/clear`) | -| `Ctrl+V` | Paste clipboard image (same as `/paste`) | -| `Esc` | Clear the current draft | -| `Tab` | Apply the active completion | -| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history | -| `Left/Right` | Move the cursor | -| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key | -| `Home` / `Ctrl+A` | Start of line | -| `End` / `Ctrl+E` | End of line | -| `Backspace` / `Delete` | Delete the character to the left of the cursor | -| modified `Backspace` / `Delete` | Delete the previous word | -| `Ctrl+W` | Delete the previous word | -| `Ctrl+U` | Delete from the cursor back to the start of the line | -| `Ctrl+K` | Delete from the cursor to the end of the line | -| `Meta+B` / `Meta+F` | Move by word | -| `!cmd` | Run a shell command through the gateway | -| `{!cmd}` | Inline shell interpolation before send or queue | +| Key | Behavior | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Enter` | Submit the current draft | +| empty `Enter` twice | If queued messages exist and the agent is busy, interrupt the current run. If queued messages exist and the agent is idle, send the next queued message | +| `Shift+Enter` / `Alt+Enter` | Insert a newline in the current draft | +| `\` + `Enter` | Append the line to the multiline buffer (fallback for terminals without modifier support) | +| `Ctrl+C` | Interrupt active run, or clear the current draft, or exit if nothing is pending | +| `Ctrl+D` | Exit | +| `Ctrl+G` | Open `$EDITOR` with the current draft | +| `Ctrl+L` | New session (same as `/clear`) | +| `Ctrl+V` | Paste clipboard image (same as `/paste`) | +| `Tab` | Apply the active completion | +| `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history | +| `Left/Right` | Move the cursor | +| modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key | +| `Home` / `Ctrl+A` | Start of line | +| `End` / `Ctrl+E` | End of line | +| `Backspace` / `Delete` | Delete the character to the left of the cursor | +| modified `Backspace` / `Delete` | Delete the previous word | +| `Ctrl+W` | Delete the previous word | +| `Ctrl+U` | Delete from the cursor back to the start of the line | +| `Ctrl+K` | Delete from the cursor to the end of the line | +| `Meta+B` / `Meta+F` | Move by word | +| `!cmd` | Run a shell command through the gateway | +| `{!cmd}` | Inline shell interpolation before send or queue | Notes: @@ -118,20 +118,20 @@ Notes: ### Prompt and picker modes -| Context | Keys | Behavior | -|---|---|---| -| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice | -| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` | -| approval prompt | `Esc`, `Ctrl+C` | Deny | -| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice | -| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice | -| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry | -| clarify free-text mode | `Enter` | Submit typed answer | -| sudo / secret prompt | `Enter` | Submit typed value | -| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response | -| resume picker | `Up/Down`, `Enter` | Move and resume the selected session | -| resume picker | `1-9` | Quick-pick one of the first nine visible sessions | -| resume picker | `Esc`, `Ctrl+C` | Close the picker | +| Context | Keys | Behavior | +| --------------------------- | ------------------- | ------------------------------------------------- | +| approval prompt | `Up/Down`, `Enter` | Move and confirm the selected approval choice | +| approval prompt | `o`, `s`, `a`, `d` | Quick-pick `once`, `session`, `always`, `deny` | +| approval prompt | `Esc`, `Ctrl+C` | Deny | +| clarify prompt with choices | `Up/Down`, `Enter` | Move and confirm the selected choice | +| clarify prompt with choices | single-digit number | Quick-pick the matching numbered choice | +| clarify prompt with choices | `Enter` on "Other" | Switch into free-text entry | +| clarify free-text mode | `Enter` | Submit typed answer | +| sudo / secret prompt | `Enter` | Submit typed value | +| sudo / secret prompt | `Ctrl+C` | Cancel by sending an empty response | +| resume picker | `Up/Down`, `Enter` | Move and resume the selected session | +| resume picker | `1-9` | Quick-pick one of the first nine visible sessions | +| resume picker | `Esc`, `Ctrl+C` | Close the picker | Notes: @@ -213,28 +213,28 @@ That lets Python own aliases, plugins, skills, and registry-backed commands with Primary event types the client handles today: -| Event | Payload | -|---|---| -| `gateway.ready` | `{ skin? }` | -| `session.info` | session metadata for banner + tool/skill panels | -| `message.start` | start assistant streaming | -| `message.delta` | `{ text, rendered? }` | -| `message.complete` | `{ text, rendered?, usage, status }` | -| `thinking.delta` | `{ text }` | -| `reasoning.delta` | `{ text }` | -| `status.update` | `{ kind, text }` | -| `tool.start` | `{ tool_id, name, context? }` | -| `tool.progress` | `{ name, preview }` | -| `tool.complete` | `{ tool_id, name }` | -| `clarify.request` | `{ question, choices?, request_id }` | -| `approval.request` | `{ command, description }` | -| `sudo.request` | `{ request_id }` | -| `secret.request` | `{ prompt, env_var, request_id }` | -| `background.complete` | `{ task_id, text }` | -| `btw.complete` | `{ text }` | -| `error` | `{ message }` | -| `gateway.stderr` | synthesized from child stderr | -| `gateway.protocol_error` | synthesized from malformed stdout | +| Event | Payload | +| ------------------------ | ----------------------------------------------- | +| `gateway.ready` | `{ skin? }` | +| `session.info` | session metadata for banner + tool/skill panels | +| `message.start` | start assistant streaming | +| `message.delta` | `{ text, rendered? }` | +| `message.complete` | `{ text, rendered?, usage, status }` | +| `thinking.delta` | `{ text }` | +| `reasoning.delta` | `{ text }` | +| `status.update` | `{ kind, text }` | +| `tool.start` | `{ tool_id, name, context? }` | +| `tool.progress` | `{ name, preview }` | +| `tool.complete` | `{ tool_id, name }` | +| `clarify.request` | `{ question, choices?, request_id }` | +| `approval.request` | `{ command, description }` | +| `sudo.request` | `{ request_id }` | +| `secret.request` | `{ prompt, env_var, request_id }` | +| `background.complete` | `{ task_id, text }` | +| `btw.complete` | `{ text }` | +| `error` | `{ message }` | +| `gateway.stderr` | synthesized from child stderr | +| `gateway.protocol_error` | synthesized from malformed stdout | ## Theme model diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 93fc5159c2..0ac8156117 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -104,30 +104,83 @@ const stripTokens = (text: string, re: RegExp) => // ── StatusRule ──────────────────────────────────────────────────────── +function ctxBarColor(pct: number | undefined, t: Theme) { + if (pct == null) { + return t.color.dim + } + + if (pct >= 95) { + return t.color.statusCritical + } + + if (pct > 80) { + return t.color.statusBad + } + + if (pct >= 50) { + return t.color.statusWarn + } + + return t.color.statusGood +} + +function ctxBar(pct: number | undefined, w = 10) { + const p = Math.max(0, Math.min(100, pct ?? 0)) + const filled = Math.round((p / 100) * w) + + return '█'.repeat(filled) + '░'.repeat(w - filled) +} + function StatusRule({ cols, - color, - dimColor, + status, statusColor, - parts + model, + usage, + bgCount, + t }: { cols: number - color: string - dimColor: string + status: string statusColor: string - parts: (string | false | undefined | null)[] + model: string + usage: Usage + bgCount: number + t: Theme }) { - const label = parts.filter(Boolean).join(' · ') - const lead = String(parts[0] ?? '') + const pct = usage.context_percent + const barColor = ctxBarColor(pct, t) + + const ctxLabel = usage.context_max + ? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` + : usage.total > 0 + ? `${fmtK(usage.total)} tok` + : '' + + const pctLabel = pct != null ? `${pct}%` : '' + const bar = usage.context_max ? ctxBar(pct) : '' + + const segs = [status, model, ctxLabel, bar ? `[${bar}]` : '', pctLabel, bgCount > 0 ? `${bgCount} bg` : ''].filter( + Boolean + ) + + const inner = segs.join(' │ ') + const pad = Math.max(0, cols - inner.length - 5) return ( - + {'─ '} - - {parts[0]} - {label.slice(lead.length)} - - {' ' + '─'.repeat(Math.max(0, cols - label.length - 5))} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} + {bar ? ( + + {' │ '} + [{bar}] {pctLabel} + + ) : null} + {bgCount > 0 ? │ {bgCount} bg : null} + {' ' + '─'.repeat(pad)} ) } @@ -186,7 +239,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [secret, setSecret] = useState(null) const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') - const [thinkingText, setThinkingText] = useState('') const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [pastes, setPastes] = useState([]) @@ -201,13 +253,16 @@ export function App({ gw }: { gw: GatewayClient }) { const buf = useRef('') const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) + const reasoningRef = useRef('') const slashRef = useRef<(cmd: string) => boolean>(() => false) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) const pasteCounterRef = useRef(0) const colsRef = useRef(cols) + const turnToolsRef = useRef([]) colsRef.current = cols + reasoningRef.current = reasoning // ── Hooks ──────────────────────────────────────────────────────── @@ -275,15 +330,12 @@ export function App({ gw }: { gw: GatewayClient }) { const idle = () => { setThinking(false) setTools([]) - setActivity([]) setBusy(false) setClarify(null) setApproval(null) setPasteReview(null) setSudo(null) setSecret(null) - setReasoning('') - setThinkingText('') setStreaming('') buf.current = '' } @@ -330,6 +382,11 @@ export function App({ gw }: { gw: GatewayClient }) { if (r.info) { setInfo(r.info) + + if (r.info.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + appendHistory(introMsg(r.info)) } else { setInfo(null) @@ -766,6 +823,9 @@ export function App({ gw }: { gw: GatewayClient }) { } idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] setStatus('interrupted') setTimeout(() => setStatus('ready'), 1500) } else if (input || inputBuf.length) { @@ -797,10 +857,6 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.ctrl && ch === 'g') { return openEditor() } - - if (key.escape) { - clearIn() - } }) // ── Gateway events ─────────────────────────────────────────────── @@ -839,13 +895,13 @@ export function App({ gw }: { gw: GatewayClient }) { case 'session.info': setInfo(p as SessionInfo) + if (p?.usage) { + setUsage(prev => ({ ...prev, ...p.usage })) + } + break case 'thinking.delta': - if (p?.text) { - setThinkingText(prev => prev + p.text) - } - break case 'message.start': @@ -853,7 +909,8 @@ export function App({ gw }: { gw: GatewayClient }) { setTurnKey(k => k + 1) setBusy(true) setReasoning('') - setThinkingText('') + setActivity([]) + turnToolsRef.current = [] break @@ -913,7 +970,9 @@ export function App({ gw }: { gw: GatewayClient }) { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' - pushActivity(`${label}${ctx ? ': ' + ctx : ''} ${mark}`, p.error ? 'error' : 'info') + const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` + pushActivity(line, p.error ? 'error' : 'info') + turnToolsRef.current = [...turnToolsRef.current, line].slice(-8) return prev.filter(t => t.id !== p.tool_id) }) @@ -976,7 +1035,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.complete': { const wasInterrupted = interruptedRef.current + const savedReasoning = reasoningRef.current.trim() + const savedTools = [...turnToolsRef.current] idle() + setReasoning('') setStreaming('') if (inflightPasteIdsRef.current.length) { @@ -985,9 +1047,17 @@ export function App({ gw }: { gw: GatewayClient }) { } if (!wasInterrupted) { - appendMessage({ role: 'assistant', text: (p?.rendered ?? p?.text ?? buf.current).trimStart() }) + appendMessage({ + role: 'assistant', + text: (p?.rendered ?? p?.text ?? buf.current).trimStart(), + thinking: savedReasoning || undefined, + tools: savedTools.length ? savedTools : undefined + }) } + turnToolsRef.current = [] + setActivity([]) + buf.current = '' setStatus('ready') @@ -1012,6 +1082,9 @@ export function App({ gw }: { gw: GatewayClient }) { inflightPasteIdsRef.current = [] sys(`error: ${p?.message}`) idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] setStatus('ready') break @@ -1498,6 +1571,9 @@ export function App({ gw }: { gw: GatewayClient }) { } idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] setStatus('interrupted') setTimeout(() => setStatus('ready'), 1500) @@ -1577,7 +1653,7 @@ export function App({ gw }: { gw: GatewayClient }) { )} - + {busy && } {pasteReview && ( @@ -1663,6 +1739,10 @@ export function App({ gw }: { gw: GatewayClient }) { setSid(r.session_id) setInfo(r.info ?? null) + if (r.info?.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + if (r.info) { appendHistory(introMsg(r.info)) } @@ -1692,43 +1772,43 @@ export function App({ gw }: { gw: GatewayClient }) { {statusBar && ( 0 && `${bgTasks.size} bg`, - usage.total > 0 && `${fmtK(usage.total)} tok` - ]} + model={info?.model?.split('/').pop() ?? ''} + status={status} statusColor={statusColor} + t={theme} + usage={usage} /> )} {!isBlocked && ( - - - - {inputBuf.length ? '… ' : `${theme.brand.prompt} `} - - + + {inputBuf.map((line, i) => ( + + + {i === 0 ? `${theme.brand.prompt} ` : ' '} + - + {line || ' '} + + ))} + + + + + {inputBuf.length ? ' ' : `${theme.brand.prompt} `} + + + + + )} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index cdec6f3e7a..71246e4736 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -53,6 +53,12 @@ export const MessageLine = memo(function MessageLine({ return ( + {msg.thinking && ( + + 💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)} + + )} + @@ -62,6 +68,20 @@ export const MessageLine = memo(function MessageLine({ {content} + + {!!msg.tools?.length && ( + + {msg.tools.map((tool, i) => ( + + {t.brand.tool} {tool} + + ))} + + )} ) }) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 38d45358cf..e7b92dc38d 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -117,7 +117,11 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } if (k.return) { - onSubmit?.(value) + if (k.shift || k.meta) { + commit(value.slice(0, cur) + '\n' + value.slice(cur), cur + 1) + } else { + onSubmit?.(value) + } return } @@ -163,6 +167,12 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } + if (raw === '\n') { + commit(v.slice(0, c) + '\n' + v.slice(c), c + 1) + + return + } + if (raw.length > 1 || raw.includes('\n')) { if (!pasteBuf.current) { pastePos.current = c diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a813765bb0..b2aff03550 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -44,6 +44,7 @@ export const Thinking = memo(function Thinking({ const verb = VERBS[tick % VERBS.length] ?? 'thinking' const face = FACES[tick % FACES.length] ?? '(•_•)' const tail = reasoning.slice(-160).replace(/\n/g, ' ') + const hasReasoning = !!tail return ( <> @@ -54,7 +55,7 @@ export const Thinking = memo(function Thinking({ ))} - {!tools.length && ( + {!tools.length && !hasReasoning && ( {face} {verb}… diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 63e5f8da3f..9734b0c277 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -27,13 +27,13 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+V', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], - ['Esc', 'clear input'], ['Ctrl+A/E', 'home / end of line'], ['Ctrl+W', 'delete word'], ['Ctrl+U/K', 'delete to start / end'], ['Ctrl+←/→', 'jump word'], ['Home/End', 'start / end of line'], - ['\\+Enter', 'multi-line continuation'], + ['Shift+Enter / Alt+Enter', 'insert newline'], + ['\\+Enter', 'multi-line continuation (fallback)'], ['!cmd', 'run shell command'], ['{!cmd}', 'interpolate shell output inline'] ] diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 5c6f0a76ac..3254c2674a 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -26,6 +26,8 @@ export interface Msg { text: string kind?: 'intro' info?: SessionInfo + thinking?: string + tools?: string[] } export type Role = 'assistant' | 'system' | 'tool' | 'user' @@ -43,6 +45,9 @@ export interface SessionInfo { export interface Usage { calls: number + context_max?: number + context_percent?: number + context_used?: number input: number output: number total: number From 54bd25ff4a067c16f04af2b3807de339edda5b48 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 00:36:53 -0500 Subject: [PATCH 035/157] fix(tui): -c resume, ctrl z, pasting updates, exit summary, session fix --- hermes_cli/main.py | 85 ++++++++++++++-- run_agent.py | 6 +- tests/hermes_cli/test_tui_resume_flow.py | 119 +++++++++++++++++++++++ tests/tui_gateway/test_protocol.py | 46 +++++++++ tui_gateway/server.py | 31 ++++-- ui-tui/src/__tests__/text.test.ts | 100 +++---------------- ui-tui/src/app.tsx | 108 +++++++++++++++----- ui-tui/src/components/messageLine.tsx | 2 +- ui-tui/src/components/textInput.tsx | 70 ++++++++++--- ui-tui/src/constants.ts | 1 + ui-tui/src/lib/text.ts | 4 + 11 files changed, 426 insertions(+), 146 deletions(-) create mode 100644 tests/hermes_cli/test_tui_resume_flow.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e5ddf497a6..35dc605f92 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -514,12 +514,12 @@ def _session_browse_picker(sessions: list) -> Optional[str]: return None -def _resolve_last_cli_session() -> Optional[str]: - """Look up the most recent CLI session ID from SQLite. Returns None if unavailable.""" +def _resolve_last_session(source: str = "cli") -> Optional[str]: + """Look up the most recent session ID for a source.""" try: from hermes_state import SessionDB db = SessionDB() - sessions = db.search_sessions(source="cli", limit=1) + sessions = db.search_sessions(source=source, limit=1) db.close() if sessions: return sessions[0]["id"] @@ -554,7 +554,58 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: return None -def _launch_tui(): +def _print_tui_exit_summary(session_id: Optional[str]) -> None: + """Print a shell-visible epilogue after TUI exits.""" + target = session_id or _resolve_last_session(source="tui") + if not target: + return + + db = None + try: + from hermes_state import SessionDB + db = SessionDB() + session = db.get_session(target) + if not session: + return + + title = db.get_session_title(target) + message_count = int(session.get("message_count") or 0) + input_tokens = int(session.get("input_tokens") or 0) + output_tokens = int(session.get("output_tokens") or 0) + cache_read_tokens = int(session.get("cache_read_tokens") or 0) + cache_write_tokens = int(session.get("cache_write_tokens") or 0) + reasoning_tokens = int(session.get("reasoning_tokens") or 0) + total_tokens = ( + input_tokens + + output_tokens + + cache_read_tokens + + cache_write_tokens + + reasoning_tokens + ) + except Exception: + return + finally: + if db is not None: + db.close() + + print() + print("Resume this session with:") + print(f" hermes --tui --resume {target}") + if title: + print(f" hermes --tui -c \"{title}\"") + print() + print(f"Session: {target}") + if title: + print(f"Title: {title}") + print(f"Messages: {message_count}") + print( + "Tokens: " + f"{total_tokens} (in {input_tokens}, out {output_tokens}, " + f"cache {cache_read_tokens + cache_write_tokens}, reasoning {reasoning_tokens})" + ) + + +def _launch_tui(resume_session_id: Optional[str] = None): """Replace current process with the Ink TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" @@ -589,19 +640,26 @@ def _launch_tui(): sys.exit(1) argv = [npm, "start"] + env = os.environ.copy() + if resume_session_id: + env["HERMES_TUI_RESUME"] = resume_session_id + try: - code = subprocess.call(argv, cwd=str(tui_dir)) + code = subprocess.call(argv, cwd=str(tui_dir), env=env) except KeyboardInterrupt: code = 130 + + if code in (0, 130): + _print_tui_exit_summary(resume_session_id) + sys.exit(code) def cmd_chat(args): """Run interactive chat CLI.""" - if getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1": - _launch_tui() + use_tui = getattr(args, "tui", False) or os.environ.get("HERMES_TUI") == "1" - # Resolve --continue into --resume with the latest CLI session or by name + # Resolve --continue into --resume with the latest session or by name continue_val = getattr(args, "continue_last", None) if continue_val and not getattr(args, "resume", None): if isinstance(continue_val, str): @@ -615,11 +673,15 @@ def cmd_chat(args): sys.exit(1) else: # -c with no argument — continue the most recent session - last_id = _resolve_last_cli_session() + source = "tui" if use_tui else "cli" + last_id = _resolve_last_session(source=source) + if not last_id and source == "tui": + last_id = _resolve_last_session(source="cli") if last_id: args.resume = last_id else: - print("No previous CLI session found to continue.") + kind = "TUI" if use_tui else "CLI" + print(f"No previous {kind} session found to continue.") sys.exit(1) # Resolve --resume by title if it's not a direct session ID @@ -631,6 +693,9 @@ def cmd_chat(args): # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input + if use_tui: + _launch_tui(getattr(args, "resume", None)) + # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() diff --git a/run_agent.py b/run_agent.py index f57072e9e5..fd1337cbb7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -7617,7 +7617,7 @@ class AIAgent: # Longer backoff for rate limiting (likely cause of None choices) # Jittered exponential: 5s base, 120s cap + random jitter wait_time = jittered_backoff(retry_count, base_delay=5.0, max_delay=120.0) - self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time}s (extended backoff for possible rate limit)...", force=True) + self._vprint(f"{self.log_prefix}⏳ Retrying in {wait_time:.1f}s (extended backoff)...", force=True) logging.warning(f"Invalid API response (retry {retry_count}/{max_retries}): {', '.join(error_details)} | Provider: {provider_name}") # Sleep in small increments to stay responsive to interrupts @@ -8505,9 +8505,9 @@ class AIAgent: pass wait_time = _retry_after if _retry_after else jittered_backoff(retry_count, base_delay=2.0, max_delay=60.0) if is_rate_limited: - self._emit_status(f"⏱️ Rate limit reached. Waiting {wait_time}s before retry (attempt {retry_count + 1}/{max_retries})...") + self._emit_status(f"⏱️ Rate limited. Waiting {wait_time:.1f}s (attempt {retry_count + 1}/{max_retries})...") else: - self._emit_status(f"⏳ Retrying in {wait_time}s (attempt {retry_count}/{max_retries})...") + self._emit_status(f"⏳ Retrying in {wait_time:.1f}s (attempt {retry_count}/{max_retries})...") logger.warning( "Retrying API call in %ss (attempt %s/%s) %s error=%s", wait_time, diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py new file mode 100644 index 0000000000..1d4ff429af --- /dev/null +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -0,0 +1,119 @@ +from argparse import Namespace +import sys +import types + +import pytest + + +def _args(**overrides): + base = { + "continue_last": None, + "resume": None, + "tui": True, + } + base.update(overrides) + return Namespace(**base) + + +def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch): + import hermes_cli.main as main_mod + + calls = [] + captured = {} + + def fake_resolve_last(source="cli"): + calls.append(source) + return "20260408_235959_a1b2c3" if source == "tui" else None + + def fake_launch(resume_session_id=None): + captured["resume"] = resume_session_id + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last) + monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val) + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat(_args(continue_last=True)) + + assert calls == ["tui"] + assert captured["resume"] == "20260408_235959_a1b2c3" + + +def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch): + import hermes_cli.main as main_mod + + calls = [] + captured = {} + + def fake_resolve_last(source="cli"): + calls.append(source) + if source == "tui": + return None + if source == "cli": + return "20260408_235959_d4e5f6" + return None + + def fake_launch(resume_session_id=None): + captured["resume"] = resume_session_id + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_resolve_last_session", fake_resolve_last) + monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: val) + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat(_args(continue_last=True)) + + assert calls == ["tui", "cli"] + assert captured["resume"] == "20260408_235959_d4e5f6" + + +def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch): + import hermes_cli.main as main_mod + + captured = {} + + def fake_launch(resume_session_id=None): + captured["resume"] = resume_session_id + raise SystemExit(0) + + monkeypatch.setattr(main_mod, "_resolve_session_by_name_or_id", lambda val: "20260409_000000_aa11bb") + monkeypatch.setattr(main_mod, "_launch_tui", fake_launch) + + with pytest.raises(SystemExit): + main_mod.cmd_chat(_args(resume="my t0p session")) + + assert captured["resume"] == "20260409_000000_aa11bb" + + +def test_print_tui_exit_summary_includes_resume_and_token_totals(monkeypatch, capsys): + import hermes_cli.main as main_mod + + class _FakeDB: + def get_session(self, session_id): + assert session_id == "20260409_000001_abc123" + return { + "message_count": 2, + "input_tokens": 10, + "output_tokens": 6, + "cache_read_tokens": 2, + "cache_write_tokens": 2, + "reasoning_tokens": 1, + } + + def get_session_title(self, _session_id): + return "demo title" + + def close(self): + return None + + monkeypatch.setitem(sys.modules, "hermes_state", types.SimpleNamespace(SessionDB=lambda: _FakeDB())) + + main_mod._print_tui_exit_summary("20260409_000001_abc123") + out = capsys.readouterr().out + + assert "Resume this session with:" in out + assert "hermes --tui --resume 20260409_000001_abc123" in out + assert 'hermes --tui -c "demo title"' in out + assert "Tokens: 21 (in 10, out 6, cache 4, reasoning 1)" in out diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 7e9d519ee8..7a000c92b6 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -151,6 +151,52 @@ def test_sess_found(server): assert err is None +# ── session.resume payload ──────────────────────────────────────────── + + +def test_session_resume_returns_hydrated_messages(server, monkeypatch): + class _DB: + def get_session(self, _sid): + return {"id": "20260409_010101_abc123"} + + def get_session_by_title(self, _title): + return None + + def reopen_session(self, _sid): + return None + + def get_messages(self, _sid): + return [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "yo"}, + {"role": "tool", "content": "searched"}, + {"role": "assistant", "content": " "}, + {"role": "assistant", "content": None}, + {"role": "narrator", "content": "skip"}, + ] + + monkeypatch.setattr(server, "_get_db", lambda: _DB()) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: object()) + monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None) + monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "test/model"}) + + resp = server.handle_request( + { + "id": "r1", + "method": "session.resume", + "params": {"session_id": "20260409_010101_abc123", "cols": 100}, + } + ) + + assert "error" not in resp + assert resp["result"]["message_count"] == 3 + assert resp["result"]["messages"] == [ + {"role": "user", "text": "hello"}, + {"role": "assistant", "text": "yo"}, + {"role": "tool", "text": "searched"}, + ] + + # ── Config I/O ─────────────────────────────────────────────────────── diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 654c9e9e39..9977d40f5c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -5,6 +5,7 @@ import subprocess import sys import threading import uuid +from datetime import datetime from pathlib import Path from hermes_constants import get_hermes_home @@ -364,6 +365,10 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): _emit("session.info", sid, _session_info(agent)) +def _new_session_key() -> str: + return f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + + def _with_checkpoints(session, fn): return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) @@ -405,7 +410,7 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: @method("session.create") def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] - key = f"tui-{sid}" + key = _new_session_key() os.environ["HERMES_SESSION_KEY"] = key os.environ["HERMES_INTERACTIVE"] = "1" try: @@ -448,14 +453,28 @@ def _(rid, params: dict) -> dict: os.environ["HERMES_INTERACTIVE"] = "1" try: db.reopen_session(target) - history = [{"role": m["role"], "content": m["content"]} - for m in db.get_messages(target) - if m.get("role") in ("user", "assistant", "tool", "system")] + messages = [ + {"role": m["role"], "text": m["content"] or ""} + for m in db.get_messages(target) + if m.get("role") in ("user", "assistant", "tool", "system") + and isinstance(m.get("content"), str) + and (m.get("content") or "").strip() + ] + history = [{"role": m["role"], "content": m["text"]} for m in messages] agent = _make_agent(sid, target, session_id=target) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") - return _ok(rid, {"session_id": sid, "resumed": target, "message_count": len(history), "info": _session_info(agent)}) + return _ok( + rid, + { + "session_id": sid, + "resumed": target, + "message_count": len(messages), + "messages": messages, + "info": _session_info(agent), + }, + ) @method("session.title") @@ -538,7 +557,7 @@ def _(rid, params: dict) -> dict: history = session.get("history", []) if not history: return _err(rid, 4008, "nothing to branch — send a message first") - new_key = f"tui-{uuid.uuid4().hex[:8]}" + new_key = _new_session_key() branch_name = params.get("name", "") try: if branch_name: diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index be93126c72..e96cbac3dd 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,98 +1,20 @@ import { describe, expect, it } from 'vitest' -import { - compactPreview, - estimateRows, - fmtK, - hasAnsi, - hasInterpolation, - pick, - stripAnsi, - userDisplay -} from '../lib/text.js' +import { sameToolTrailGroup } from '../lib/text.js' -describe('stripAnsi / hasAnsi', () => { - it('strips ANSI codes', () => { - expect(stripAnsi('\x1b[31mred\x1b[0m')).toBe('red') +describe('sameToolTrailGroup', () => { + it('matches bare check lines', () => { + expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✓')).toBe(true) + expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✗')).toBe(true) }) - it('passes plain text through', () => { - expect(stripAnsi('hello')).toBe('hello') + it('matches contextual lines', () => { + expect(sameToolTrailGroup('🔍 searching', '🔍 searching: * ✓')).toBe(true) + expect(sameToolTrailGroup('🔍 searching', '🔍 searching: foo ✓')).toBe(true) }) - it('detects ANSI', () => { - expect(hasAnsi('\x1b[1mbold\x1b[0m')).toBe(true) - expect(hasAnsi('plain')).toBe(false) - }) -}) - -describe('compactPreview', () => { - it('truncates with ellipsis', () => { - expect(compactPreview('a'.repeat(100), 20)).toHaveLength(20) - expect(compactPreview('a'.repeat(100), 20).at(-1)).toBe('…') - }) - - it('returns short strings as-is', () => { - expect(compactPreview('hello', 20)).toBe('hello') - }) - - it('collapses whitespace', () => { - expect(compactPreview(' a b ', 20)).toBe('a b') - }) - - it('returns empty for whitespace-only', () => { - expect(compactPreview(' ', 20)).toBe('') - }) -}) - -describe('estimateRows', () => { - it('single line', () => expect(estimateRows('hello', 80)).toBe(1)) - - it('wraps long lines', () => expect(estimateRows('a'.repeat(160), 80)).toBe(2)) - - it('counts newlines', () => expect(estimateRows('a\nb\nc', 80)).toBe(3)) - - it('skips table separators', () => { - expect(estimateRows('| a | b |\n|---|---|\n| 1 | 2 |', 80)).toBe(2) - }) - - it('handles code blocks', () => { - expect(estimateRows('```python\nprint("hi")\n```', 80)).toBeGreaterThanOrEqual(2) - }) - - it('compact mode skips empty lines', () => { - expect(estimateRows('a\n\nb', 80, true)).toBe(2) - expect(estimateRows('a\n\nb', 80, false)).toBe(3) - }) -}) - -describe('fmtK', () => { - it('formats thousands', () => expect(fmtK(1500)).toBe('1.5k')) - - it('keeps small numbers', () => expect(fmtK(42)).toBe('42')) - - it('boundary', () => { - expect(fmtK(1000)).toBe('1.0k') - expect(fmtK(999)).toBe('999') - }) -}) - -describe('hasInterpolation', () => { - it('detects {!cmd}', () => expect(hasInterpolation('echo {!date}')).toBe(true)) - - it('rejects plain text', () => expect(hasInterpolation('plain')).toBe(false)) -}) - -describe('pick', () => { - it('returns element from array', () => { - expect([1, 2, 3]).toContain(pick([1, 2, 3])) - }) -}) - -describe('userDisplay', () => { - it('returns short messages as-is', () => expect(userDisplay('hello')).toBe('hello')) - - it('truncates long messages', () => { - expect(userDisplay('word '.repeat(100))).toContain('[long message]') + it('rejects other tools', () => { + expect(sameToolTrailGroup('🔍 searching', '📖 reading ✓')).toBe(false) + expect(sameToolTrailGroup('🔍 searching', '🔍 searching extra ✓')).toBe(false) }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 0ac8156117..d17d0c0fbc 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -21,7 +21,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { compactPreview, fmtK, hasInterpolation, pick } from './lib/text.js' +import { compactPreview, fmtK, hasInterpolation, pick, sameToolTrailGroup } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -42,8 +42,8 @@ import type { const PLACEHOLDER = pick(PLACEHOLDERS) const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g +const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() -const SMALL_PASTE = { chars: 400, lines: 4 } const LARGE_PASTE = { chars: 8000, lines: 80 } const EXCERPT = { chars: 1200, lines: 14 } @@ -102,6 +102,31 @@ const stripTokens = (text: string, re: RegExp) => .replace(/\s{2,}/g, ' ') .trim() +const toTranscriptMessages = (rows: unknown): Msg[] => { + if (!Array.isArray(rows)) { + return [] + } + + return rows.flatMap(row => { + if (!row || typeof row !== 'object') { + return [] + } + + const role = (row as any).role + const text = (row as any).text + + if ( + (role !== 'assistant' && role !== 'system' && role !== 'tool' && role !== 'user') || + typeof text !== 'string' || + !text.trim() + ) { + return [] + } + + return [{ role, text }] + }) +} + // ── StatusRule ──────────────────────────────────────────────────────── function ctxBarColor(pct: number | undefined, t: Theme) { @@ -250,6 +275,7 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Refs ───────────────────────────────────────────────────────── const activityIdRef = useRef(0) + const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null) const buf = useRef('') const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) @@ -301,21 +327,19 @@ export function App({ gw }: { gw: GatewayClient }) { setHistoryItems(prev => [...prev, msg]) }, []) - const appendHistory = useCallback((msg: Msg) => { - setHistoryItems(prev => [...prev, msg]) - }, []) - const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info') => { + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(prev => { - if (prev.at(-1)?.text === text && prev.at(-1)?.tone === tone) { - return prev + const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev + + if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) { + return base } activityIdRef.current++ - return [...prev, { id: activityIdRef.current, text, tone }].slice(-8) + return [...base, { id: activityIdRef.current, text, tone }].slice(-8) }) }, []) @@ -387,7 +411,7 @@ export function App({ gw }: { gw: GatewayClient }) { setUsage(prev => ({ ...prev, ...r.info.usage })) } - appendHistory(introMsg(r.info)) + setHistoryItems([introMsg(r.info)]) } else { setInfo(null) } @@ -396,7 +420,7 @@ export function App({ gw }: { gw: GatewayClient }) { sys(msg) } }), - [appendHistory, rpc, sys] + [rpc, sys] ) // ── Paste pipeline ─────────────────────────────────────────────── @@ -466,10 +490,17 @@ export function App({ gw }: { gw: GatewayClient }) { const handleTextPaste = useCallback( ({ cursor, text, value }: { cursor: number; text: string; value: string }) => { + const lineCount = text.split('\n').length + + // Inline normal paste payloads exactly as typed. Only very large + // payloads are tokenized into attached snippets. + if (text.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { + return { cursor: cursor + text.length, value: value.slice(0, cursor) + text + value.slice(cursor) } + } + pasteCounterRef.current++ const id = pasteCounterRef.current - const lineCount = text.split('\n').length - const mode: PasteMode = lineCount > SMALL_PASTE.lines || text.length > SMALL_PASTE.chars ? 'attach' : 'excerpt' + const mode: PasteMode = 'attach' const token = pasteToken(id) const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' @@ -887,8 +918,31 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => {}) - setStatus('forging session…') - newSession() + if (STARTUP_RESUME_ID) { + setStatus('resuming…') + gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) + .then((r: any) => { + resetSession() + setSid(r.session_id) + setInfo(r.info ?? null) + const resumed = toTranscriptMessages(r.messages) + + if (r.info?.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + setStatus('ready') + }) + .catch(() => { + setStatus('forging session…') + newSession('resume failed, started a new session') + }) + } else { + setStatus('forging session…') + newSession() + } break @@ -965,18 +1019,26 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.complete': { const mark = p.error ? '✗' : '✓' + const tone = p.error ? 'error' : 'info' + toolCompleteRibbonRef.current = null setTools(prev => { const done = prev.find(t => t.id === p.tool_id) const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` - pushActivity(line, p.error ? 'error' : 'info') - turnToolsRef.current = [...turnToolsRef.current, line].slice(-8) + + toolCompleteRibbonRef.current = { label, line } + turnToolsRef.current = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) return prev.filter(t => t.id !== p.tool_id) }) + if (toolCompleteRibbonRef.current) { + const { line, label } = toolCompleteRibbonRef.current + pushActivity(line, tone, label) + } + break } @@ -1733,21 +1795,19 @@ export function App({ gw }: { gw: GatewayClient }) { onSelect={id => { setPicker(false) setStatus('resuming…') - gw.request('session.resume', { cols, session_id: id }) + gw.request('session.resume', { cols: colsRef.current, session_id: id }) .then((r: any) => { resetSession() setSid(r.session_id) setInfo(r.info ?? null) + const resumed = toTranscriptMessages(r.messages) if (r.info?.usage) { setUsage(prev => ({ ...prev, ...r.info.usage })) } - if (r.info) { - appendHistory(introMsg(r.info)) - } - - sys(`resumed session (${r.message_count} messages)`) + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) setStatus('ready') }) .catch((e: Error) => { diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 71246e4736..76d0d17430 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -70,7 +70,7 @@ export const MessageLine = memo(function MessageLine({ {!!msg.tools?.length && ( - + {msg.tools.map((tool, i) => ( | null>(null) const pastePos = useRef(0) + const undo = useRef>([]) + const redo = useRef>([]) + curRef.current = cur vRef.current = value useEffect(() => { @@ -60,16 +64,34 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' selfChange.current = false } else { setCur(value.length) + curRef.current = value.length + undo.current = [] + redo.current = [] } }, [value]) - const commit = (v: string, c: number) => { - c = Math.max(0, Math.min(c, v.length)) - setCur(c) + const commit = (nextValue: string, nextCursor: number, track = true) => { + const currentValue = vRef.current + const currentCursor = curRef.current + const c = Math.max(0, Math.min(nextCursor, nextValue.length)) - if (v !== value) { + if (track && nextValue !== currentValue) { + undo.current.push({ cursor: currentCursor, value: currentValue }) + + if (undo.current.length > 200) { + undo.current.shift() + } + + redo.current = [] + } + + setCur(c) + curRef.current = c + vRef.current = nextValue + + if (nextValue !== currentValue) { selfChange.current = true - onChange(v) + onChange(nextValue) } } @@ -83,21 +105,17 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } - const v = vRef.current - const handled = onPaste?.({ cursor: at, text: pasted, value: v }) + const currentValue = vRef.current + const handled = onPaste?.({ cursor: at, text: pasted, value: currentValue }) if (handled) { - selfChange.current = true - onChange(handled.value) - setCur(handled.cursor) + commit(handled.value, handled.cursor) return } if (pasted.length && PRINTABLE.test(pasted)) { - selfChange.current = true - onChange(v.slice(0, at) + pasted + v.slice(at)) - setCur(at + pasted.length) + commit(currentValue.slice(0, at) + pasted + currentValue.slice(at), at + pasted.length) } } @@ -130,6 +148,32 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' let v = value const mod = k.ctrl || k.meta + if (k.ctrl && inp === 'z') { + const prev = undo.current.pop() + + if (!prev) { + return + } + + redo.current.push({ cursor: curRef.current, value: vRef.current }) + commit(prev.value, prev.cursor, false) + + return + } + + if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { + const next = redo.current.pop() + + if (!next) { + return + } + + undo.current.push({ cursor: curRef.current, value: vRef.current }) + commit(next.value, next.cursor, false) + + return + } + if (k.home || (k.ctrl && inp === 'a')) { c = 0 } else if (k.end || (k.ctrl && inp === 'e')) { diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 9734b0c277..36dd999e69 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -28,6 +28,7 @@ export const HOTKEYS: [string, string][] = [ ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], ['Ctrl+A/E', 'home / end of line'], + ['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'], ['Ctrl+W', 'delete word'], ['Ctrl+U/K', 'delete to start / end'], ['Ctrl+←/→', 'jump word'], diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index c24727484b..ac2efb2cb7 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -35,6 +35,10 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ +export const sameToolTrailGroup = (label: string, entry: string) => + entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`) + export const estimateRows = (text: string, w: number, compact = false) => { let inCode = false let rows = 0 From 8755b9dfc045f4d1e404fb1443a783fd50b30038 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 00:46:35 -0500 Subject: [PATCH 036/157] fix: resizing etc --- ui-tui/src/__tests__/text.test.ts | 18 +++++++++++++- ui-tui/src/app.tsx | 39 +++++++++++-------------------- ui-tui/src/lib/text.ts | 7 +++++- 3 files changed, 36 insertions(+), 28 deletions(-) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index e96cbac3dd..1d61b71b1f 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { sameToolTrailGroup } from '../lib/text.js' +import { fmtK, sameToolTrailGroup } from '../lib/text.js' describe('sameToolTrailGroup', () => { it('matches bare check lines', () => { @@ -18,3 +18,19 @@ describe('sameToolTrailGroup', () => { expect(sameToolTrailGroup('🔍 searching', '🔍 searching extra ✓')).toBe(false) }) }) + +describe('fmtK', () => { + it('keeps small numbers plain', () => { + expect(fmtK(999)).toBe('999') + }) + + it('formats thousands as K', () => { + expect(fmtK(1000)).toBe('1K') + expect(fmtK(1500)).toBe('1.5K') + }) + + it('formats millions and billions', () => { + expect(fmtK(1_000_000)).toBe('1M') + expect(fmtK(1_000_000_000)).toBe('1B') + }) +}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index d17d0c0fbc..5a66dce58b 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Box, Static, Text, useApp, useInput, useStdout } from 'ink' +import { Box, Text, useApp, useInput, useStdout } from 'ink' import { useCallback, useEffect, useRef, useState } from 'react' import { ActivityLane } from './components/activityLane.js' @@ -250,7 +250,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [sid, setSid] = useState(null) const [theme, setTheme] = useState(DEFAULT_THEME) const [info, setInfo] = useState(null) - const [introCollapsed, setIntroCollapsed] = useState(false) const [thinking, setThinking] = useState(false) const [turnKey, setTurnKey] = useState(0) const [activity, setActivity] = useState([]) @@ -385,7 +384,6 @@ export function App({ gw }: { gw: GatewayClient }) { setPastes([]) setActivity([]) setBgTasks(new Set()) - setIntroCollapsed(false) setUsage(ZERO) lastStatusNoteRef.current = '' protocolWarnedRef.current = false @@ -545,7 +543,6 @@ export function App({ gw }: { gw: GatewayClient }) { inflightPasteIdsRef.current = payload.usedIds setLastUserMsg(text) - setIntroCollapsed(true) appendMessage({ role: 'user', text }) setBusy(true) setStatus('running…') @@ -1683,28 +1680,18 @@ export function App({ gw }: { gw: GatewayClient }) { return ( - - {(m, i) => ( - - {m.kind === 'intro' && m.info ? ( - - {introCollapsed ? ( - - {theme.brand.icon} {theme.brand.name} · {m.info.model.split('/').pop()} - - ) : ( - <> - - - - )} - - ) : ( - - )} - - )} - + {historyItems.map((m, i) => ( + + {m.kind === 'intro' && m.info ? ( + + + + + ) : ( + + )} + + ))} {streaming && ( diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index ac2efb2cb7..264d741fde 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -80,7 +80,12 @@ export const estimateRows = (text: string, w: number, compact = false) => { export const flat = (r: Record) => Object.values(r).flat() -export const fmtK = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`) +const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 1, + notation: 'compact' +}) + +export const fmtK = (n: number) => COMPACT_NUMBER.format(n) export const hasInterpolation = (s: string) => { INTERPOLATION_RE.lastIndex = 0 From 0d7c19a42f35a5e8370109b6ffc8b8009f5e8837 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 12:21:24 -0500 Subject: [PATCH 037/157] fix(ui-tui): ref-based input buffer, gateway listener stability, usage display, and 6 correctness bugs --- tui_gateway/server.py | 24 +++++ ui-tui/src/app.tsx | 100 +++++++++++++++++---- ui-tui/src/components/textInput.tsx | 130 ++++++++++++++-------------- ui-tui/src/entry.tsx | 2 +- 4 files changed, 175 insertions(+), 81 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 9977d40f5c..da603b7272 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -232,8 +232,13 @@ def _resolve_model() -> str: def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) usage = { + "model": getattr(agent, "model", "") or "", "input": g("session_input_tokens", "session_prompt_tokens"), "output": g("session_output_tokens", "session_completion_tokens"), + "cache_read": g("session_cache_read_tokens"), + "cache_write": g("session_cache_write_tokens"), + "prompt": g("session_prompt_tokens"), + "completion": g("session_completion_tokens"), "total": g("session_total_tokens"), "calls": g("session_api_calls"), } @@ -245,6 +250,25 @@ def _get_usage(agent) -> dict: usage["context_used"] = ctx_used usage["context_max"] = ctx_max usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) + usage["compressions"] = getattr(comp, "compression_count", 0) or 0 + try: + from agent.usage_pricing import CanonicalUsage, estimate_usage_cost + cost = estimate_usage_cost( + usage["model"], + CanonicalUsage( + input_tokens=usage["input"], + output_tokens=usage["output"], + cache_read_tokens=usage["cache_read"], + cache_write_tokens=usage["cache_write"], + ), + provider=getattr(agent, "provider", None), + base_url=getattr(agent, "base_url", None), + ) + usage["cost_status"] = cost.status + if cost.amount_usd is not None: + usage["cost_usd"] = float(cost.amount_usd) + except Exception: + pass return usage diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 5a66dce58b..16152bcf9a 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -46,6 +46,7 @@ const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() const LARGE_PASTE = { chars: 8000, lines: 80 } const EXCERPT = { chars: 1200, lines: 14 } +const MAX_HISTORY = 800 const SECRET_PATTERNS = [ /AKIA[0-9A-Z]{16}/g, @@ -286,6 +287,8 @@ export function App({ gw }: { gw: GatewayClient }) { const pasteCounterRef = useRef(0) const colsRef = useRef(cols) const turnToolsRef = useRef([]) + const statusTimerRef = useRef | null>(null) + const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) colsRef.current = cols reasoningRef.current = reasoning @@ -322,8 +325,15 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Core actions ───────────────────────────────────────────────── const appendMessage = useCallback((msg: Msg) => { - setMessages(prev => [...prev, msg]) - setHistoryItems(prev => [...prev, msg]) + const cap = (items: Msg[]) => + items.length <= MAX_HISTORY + ? items + : items[0]?.kind === 'intro' + ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] + : items.slice(-MAX_HISTORY) + + setMessages(prev => cap([...prev, msg])) + setHistoryItems(prev => cap([...prev, msg])) }, []) const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) @@ -378,6 +388,8 @@ export function App({ gw }: { gw: GatewayClient }) { } const resetSession = () => { + idle() + setReasoning('') setSid(null as any) // will be set by caller setHistoryItems([]) setMessages([]) @@ -385,6 +397,7 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity([]) setBgTasks(new Set()) setUsage(ZERO) + turnToolsRef.current = [] lastStatusNoteRef.current = '' protocolWarnedRef.current = false } @@ -541,6 +554,11 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') } + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + statusTimerRef.current = null + } + inflightPasteIdsRef.current = payload.usedIds setLastUserMsg(text) appendMessage({ role: 'user', text }) @@ -855,7 +873,15 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity([]) turnToolsRef.current = [] setStatus('interrupted') - setTimeout(() => setStatus('ready'), 1500) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus('ready') + }, 1500) } else if (input || inputBuf.length) { clearIn() } else { @@ -1077,7 +1103,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'btw.complete': setBgTasks(prev => { const next = new Set(prev) - next.delete(`btw:${p.task_id ?? 'x'}`) + next.delete('btw:x') return next }) @@ -1096,6 +1122,8 @@ export function App({ gw }: { gw: GatewayClient }) { const wasInterrupted = interruptedRef.current const savedReasoning = reasoningRef.current.trim() const savedTools = [...turnToolsRef.current] + const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart() + idle() setReasoning('') setStreaming('') @@ -1108,7 +1136,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!wasInterrupted) { appendMessage({ role: 'assistant', - text: (p?.rendered ?? p?.text ?? buf.current).trimStart(), + text: finalText, thinking: savedReasoning || undefined, tools: savedTools.length ? savedTools : undefined }) @@ -1152,20 +1180,24 @@ export function App({ gw }: { gw: GatewayClient }) { [appendMessage, dequeue, newSession, pushActivity, send, sys] ) - const onExit = useCallback(() => { - setStatus('gateway exited') - exit() - }, [exit]) + onEventRef.current = onEvent useEffect(() => { - gw.on('event', onEvent) - gw.on('exit', onExit) + const handler = (ev: GatewayEvent) => onEventRef.current(ev) + + const exitHandler = () => { + setStatus('gateway exited') + exit() + } + + gw.on('event', handler) + gw.on('exit', exitHandler) return () => { - gw.off('event', onEvent) - gw.off('exit', onExit) + gw.off('event', handler) + gw.off('exit', exitHandler) } - }, [gw, onEvent, onExit]) + }, [gw, exit]) // ── Slash commands ─────────────────────────────────────────────── @@ -1505,8 +1537,36 @@ export function App({ gw }: { gw: GatewayClient }) { setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) } + if (!r?.calls) { + sys('no API calls yet') + + return + } + + const f = (v: number) => (v ?? 0).toLocaleString() + const ln = (k: string, v: string) => ` ${k.padEnd(26)}${v.padStart(10)}` + const hr = ` ${'─'.repeat(36)}` + + const cost = + r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + sys( - `${fmtK(r?.input ?? 0)} in · ${fmtK(r?.output ?? 0)} out · ${fmtK(r?.total ?? 0)} total · ${r?.calls ?? 0} calls` + [ + hr, + ln('Model:', r.model ?? ''), + ln('Input tokens:', f(r.input)), + ln('Cache read tokens:', f(r.cache_read)), + ln('Cache write tokens:', f(r.cache_write)), + ln('Output tokens:', f(r.output)), + ln('Total tokens:', f(r.total)), + ln('API calls:', f(r.calls)), + cost && ln('Cost:', cost), + hr, + r.context_max && ` Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)`, + r.compressions && ` Compressions: ${r.compressions}` + ] + .filter(Boolean) + .join('\n') ) }) @@ -1634,7 +1694,15 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity([]) turnToolsRef.current = [] setStatus('interrupted') - setTimeout(() => setStatus('ready'), 1500) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus('ready') + }, 1500) return } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 8b4dc5c33f..54dcb17ebb 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -48,16 +48,22 @@ interface Props { export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { const [cur, setCur] = useState(value.length) + const curRef = useRef(cur) const vRef = useRef(value) const selfChange = useRef(false) const pasteBuf = useRef('') const pasteTimer = useRef | null>(null) const pastePos = useRef(0) - const undo = useRef>([]) - const redo = useRef>([]) - curRef.current = cur - vRef.current = value + const undoStack = useRef>([]) + const redoStack = useRef>([]) + + const onChangeRef = useRef(onChange) + const onSubmitRef = useRef(onSubmit) + const onPasteRef = useRef(onPaste) + onChangeRef.current = onChange + onSubmitRef.current = onSubmit + onPasteRef.current = onPaste useEffect(() => { if (selfChange.current) { @@ -65,36 +71,58 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } else { setCur(value.length) curRef.current = value.length - undo.current = [] - redo.current = [] + vRef.current = value + undoStack.current = [] + redoStack.current = [] } }, [value]) - const commit = (nextValue: string, nextCursor: number, track = true) => { - const currentValue = vRef.current - const currentCursor = curRef.current - const c = Math.max(0, Math.min(nextCursor, nextValue.length)) + useEffect( + () => () => { + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } + }, + [] + ) - if (track && nextValue !== currentValue) { - undo.current.push({ cursor: currentCursor, value: currentValue }) + // ── Buffer ops (synchronous, ref-based — no stale closures) ───── - if (undo.current.length > 200) { - undo.current.shift() + const commit = (next: string, nextCur: number, track = true) => { + const prev = vRef.current + const c = Math.max(0, Math.min(nextCur, next.length)) + + if (track && next !== prev) { + undoStack.current.push({ cursor: curRef.current, value: prev }) + + if (undoStack.current.length > 200) { + undoStack.current.shift() } - redo.current = [] + redoStack.current = [] } setCur(c) curRef.current = c - vRef.current = nextValue + vRef.current = next - if (nextValue !== currentValue) { + if (next !== prev) { selfChange.current = true - onChange(nextValue) + onChangeRef.current(next) } } + const swap = (from: typeof undoStack, to: typeof redoStack) => { + const entry = from.current.pop() + + if (!entry) { + return + } + + to.current.push({ cursor: curRef.current, value: vRef.current }) + commit(entry.value, entry.cursor, false) + } + const flushPaste = () => { const pasted = pasteBuf.current const at = pastePos.current @@ -105,20 +133,20 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return } - const currentValue = vRef.current - const handled = onPaste?.({ cursor: at, text: pasted, value: currentValue }) + const v = vRef.current + const handled = onPasteRef.current?.({ cursor: at, text: pasted, value: v }) if (handled) { - commit(handled.value, handled.cursor) - - return + return commit(handled.value, handled.cursor) } - if (pasted.length && PRINTABLE.test(pasted)) { - commit(currentValue.slice(0, at) + pasted + currentValue.slice(at), at + pasted.length) + if (PRINTABLE.test(pasted)) { + commit(v.slice(0, at) + pasted + v.slice(at), at + pasted.length) } } + // ── Input handler (reads only from refs) ──────────────────────── + useInput( (inp, k) => { if ( @@ -136,42 +164,24 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' if (k.return) { if (k.shift || k.meta) { - commit(value.slice(0, cur) + '\n' + value.slice(cur), cur + 1) + commit(vRef.current.slice(0, curRef.current) + '\n' + vRef.current.slice(curRef.current), curRef.current + 1) } else { - onSubmit?.(value) + onSubmitRef.current?.(vRef.current) } return } - let c = cur - let v = value + let c = curRef.current + let v = vRef.current const mod = k.ctrl || k.meta if (k.ctrl && inp === 'z') { - const prev = undo.current.pop() - - if (!prev) { - return - } - - redo.current.push({ cursor: curRef.current, value: vRef.current }) - commit(prev.value, prev.cursor, false) - - return + return swap(undoStack, redoStack) } if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { - const next = redo.current.pop() - - if (!next) { - return - } - - undo.current.push({ cursor: curRef.current, value: vRef.current }) - commit(next.value, next.cursor, false) - - return + return swap(redoStack, undoStack) } if (k.home || (k.ctrl && inp === 'a')) { @@ -212,22 +222,18 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } if (raw === '\n') { - commit(v.slice(0, c) + '\n' + v.slice(c), c + 1) - - return + return commit(v.slice(0, c) + '\n' + v.slice(c), c + 1) } if (raw.length > 1 || raw.includes('\n')) { if (!pasteBuf.current) { pastePos.current = c } - pasteBuf.current += raw if (pasteTimer.current) { clearTimeout(pasteTimer.current) } - pasteTimer.current = setTimeout(flushPaste, 50) return @@ -248,6 +254,8 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' { isActive: focus } ) + // ── Render ────────────────────────────────────────────────────── + if (!focus) { return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} } @@ -256,15 +264,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} } - let r = '' + const rendered = + [...value].map((ch, i) => (i === cur ? INV + ch + INV_OFF : ch)).join('') + + (cur === value.length ? INV + ' ' + INV_OFF : '') - for (let i = 0; i < value.length; i++) { - r += i === cur ? INV + value[i] + INV_OFF : value[i] - } - - if (cur === value.length) { - r += INV + ' ' + INV_OFF - } - - return {r} + return {rendered} } diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index b8c247d97a..29d0349573 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -11,4 +11,4 @@ if (!process.stdin.isTTY) { const gw = new GatewayClient() gw.start() -render(, { exitOnCtrlC: false }) +render(, { exitOnCtrlC: false, maxFps: 60 }) From 00e1d42b9e23554e0f8d05960f9fa1cfebbe0a6d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 13:45:23 -0500 Subject: [PATCH 038/157] feat: image pasting --- hermes_cli/clipboard.py | 9 +- tests/tools/test_clipboard.py | 12 ++ tui_gateway/server.py | 9 +- ui-tui/README.md | 2 +- ui-tui/src/app.tsx | 155 ++++++++------------- ui-tui/src/components/textInput.tsx | 208 +++++++++++----------------- ui-tui/src/constants.ts | 2 +- 7 files changed, 160 insertions(+), 237 deletions(-) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index 622c087f31..3545f4baac 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -47,10 +47,11 @@ def has_clipboard_image() -> bool: return _macos_has_image() if sys.platform == "win32": return _windows_has_image() - if _is_wsl(): - return _wsl_has_image() - if os.environ.get("WAYLAND_DISPLAY"): - return _wayland_has_image() + # Match _linux_save fallthrough order: WSL → Wayland → X11 + if _is_wsl() and _wsl_has_image(): + return True + if os.environ.get("WAYLAND_DISPLAY") and _wayland_has_image(): + return True return _xclip_has_image() diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index 82a4aa6faf..d64637ca72 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -732,6 +732,18 @@ class TestHasClipboardImage: assert has_clipboard_image() is True m.assert_called_once() + def test_wsl_falls_through_to_wayland_when_windows_path_empty(self): + """WSLg often bridges images to wl-paste even when powershell.exe check fails.""" + with patch("hermes_cli.clipboard.sys") as mock_sys: + mock_sys.platform = "linux" + with patch("hermes_cli.clipboard._is_wsl", return_value=True): + with patch("hermes_cli.clipboard._wsl_has_image", return_value=False) as wsl: + with patch.dict(os.environ, {"WAYLAND_DISPLAY": "wayland-0"}): + with patch("hermes_cli.clipboard._wayland_has_image", return_value=True) as wl: + assert has_clipboard_image() is True + wsl.assert_called_once() + wl.assert_called_once() + def test_linux_wayland_dispatch(self): with patch("hermes_cli.clipboard.sys") as mock_sys: mock_sys.platform = "linux" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index da603b7272..059bbc394e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -691,16 +691,15 @@ def _(rid, params: dict) -> dict: except Exception as e: return _err(rid, 5027, f"clipboard unavailable: {e}") - if not has_clipboard_image(): - return _ok(rid, {"attached": False, "message": "No image found in clipboard"}) - + session["image_counter"] = session.get("image_counter", 0) + 1 img_dir = _hermes_home / "images" img_dir.mkdir(parents=True, exist_ok=True) - session["image_counter"] = session.get("image_counter", 0) + 1 img_path = img_dir / f"clip_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{session['image_counter']}.png" + # Save-first: mirrors CLI keybinding path; more robust than has_image() precheck if not save_clipboard_image(img_path): - return _ok(rid, {"attached": False, "message": "Clipboard has image but extraction failed"}) + msg = "Clipboard has image but extraction failed" if has_clipboard_image() else "No image found in clipboard" + return _ok(rid, {"attached": False, "message": msg}) session.setdefault("attached_images", []).append(str(img_path)) return _ok(rid, {"attached": True, "path": str(img_path), "count": len(session["attached_images"])}) diff --git a/ui-tui/README.md b/ui-tui/README.md index 8783b18fbd..f9fbcd3f2d 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -94,7 +94,7 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an | `Ctrl+D` | Exit | | `Ctrl+G` | Open `$EDITOR` with the current draft | | `Ctrl+L` | New session (same as `/clear`) | -| `Ctrl+V` | Paste clipboard image (same as `/paste`) | +| `Ctrl+V` / `Alt+V` | Paste clipboard image (same as `/paste`) | | `Tab` | Apply the active completion | | `Up/Down` | Cycle completions if the completion list is open; otherwise edit queued messages first, then walk input history | | `Left/Right` | Move the cursor | diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 16152bcf9a..eae4043714 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -229,15 +229,18 @@ export function App({ gw }: { gw: GatewayClient }) { const [cols, setCols] = useState(stdout?.columns ?? 80) useEffect(() => { - if (!stdout) { - return - } + if (!stdout) {return} const sync = () => setCols(stdout.columns ?? 80) stdout.on('resize', sync) + // Enable bracketed paste so image-only clipboard paste reaches the app + if (stdout.isTTY) {stdout.write('\x1b[?2004h')} + return () => { stdout.off('resize', sync) + + if (stdout.isTTY) {stdout.write('\x1b[?2004l')} } }, [stdout]) @@ -499,12 +502,28 @@ export function App({ gw }: { gw: GatewayClient }) { [pastes] ) + const paste = useCallback( + (quiet = false) => + rpc('clipboard.paste', { session_id: sid }).then((r: any) => + r?.attached + ? sys(`📎 Image #${r.count} attached from clipboard`) + : quiet || sys(r?.message || 'No image found in clipboard') + ), + [rpc, sid, sys] + ) + const handleTextPaste = useCallback( - ({ cursor, text, value }: { cursor: number; text: string; value: string }) => { + ({ bracketed, cursor, hotkey, text, value }: import('./components/textInput.js').PasteEvent) => { + if (hotkey) { void paste(false); + + return null } + + if (bracketed) {void paste(true)} + + if (!text) {return null} + const lineCount = text.split('\n').length - // Inline normal paste payloads exactly as typed. Only very large - // payloads are tokenized into attached snippets. if (text.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { return { cursor: cursor + text.length, value: value.slice(0, cursor) + text + value.slice(cursor) } } @@ -536,7 +555,7 @@ export function App({ gw }: { gw: GatewayClient }) { return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, - [pushActivity] + [paste, pushActivity] ) // ── Send ───────────────────────────────────────────────────────── @@ -599,11 +618,6 @@ export function App({ gw }: { gw: GatewayClient }) { }) } - const paste = () => - rpc('clipboard.paste', { session_id: sid }).then((r: any) => - pushActivity(r.attached ? `image #${r.count} attached` : r.message || 'no image in clipboard') - ) - const openEditor = () => { const editor = process.env.EDITOR || process.env.VISUAL || 'vi' const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') @@ -756,37 +770,23 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Input handling ─────────────────────────────────────────────── + const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => + key.ctrl && ch.toLowerCase() === target + useInput((ch, key) => { if (isBlocked) { if (pasteReview) { - if (key.return) { - const t = pasteReview.text - setPasteReview(null) - dispatchSubmission(t, true) - } else if (key.escape || (key.ctrl && ch === 'c')) { - setPasteReview(null) - setStatus('ready') - } + if (key.return) { setPasteReview(null); dispatchSubmission(pasteReview.text, true) } + else if (key.escape || ctrl(key, ch, 'c')) { setPasteReview(null); setStatus('ready') } return } - if (key.ctrl && ch === 'c') { - if (approval) { - gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) - setApproval(null) - sys('denied') - } else if (sudo) { - gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) - setSudo(null) - sys('sudo cancelled') - } else if (secret) { - gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) - setSecret(null) - sys('secret entry cancelled') - } else if (picker) { - setPicker(false) - } + if (ctrl(key, ch, 'c')) { + if (approval) { gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}); setApproval(null); sys('denied') } + else if (sudo) { gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}); setSudo(null); sys('sudo cancelled') } + else if (secret) { gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}); setSecret(null); sys('secret entry cancelled') } + else if (picker) {setPicker(false)} } else if (key.escape && picker) { setPicker(false) } @@ -803,9 +803,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (!inputBuf.length && key.tab && completions.length) { const row = completions[compIdx] - if (row) { - setInput(input.slice(0, compReplace) + row.text) - } + if (row) {setInput(input.slice(0, compReplace) + row.text)} return } @@ -813,19 +811,12 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.upArrow && !inputBuf.length) { if (queueRef.current.length) { const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % queueRef.current.length - setQueueEdit(idx) - setHistoryIdx(null) - setInput(queueRef.current[idx] ?? '') + setQueueEdit(idx); setHistoryIdx(null); setInput(queueRef.current[idx] ?? '') } else if (historyRef.current.length) { const idx = historyIdx === null ? historyRef.current.length - 1 : Math.max(0, historyIdx - 1) - if (historyIdx === null) { - historyDraftRef.current = input - } - - setHistoryIdx(idx) - setQueueEdit(null) - setInput(historyRef.current[idx] ?? '') + if (historyIdx === null) {historyDraftRef.current = input} + setHistoryIdx(idx); setQueueEdit(null); setInput(historyRef.current[idx] ?? '') } return @@ -833,55 +824,32 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.downArrow && !inputBuf.length) { if (queueRef.current.length) { - const idx = - queueEditIdx === null - ? queueRef.current.length - 1 - : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length + const idx = queueEditIdx === null + ? queueRef.current.length - 1 + : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length - setQueueEdit(idx) - setHistoryIdx(null) - setInput(queueRef.current[idx] ?? '') + setQueueEdit(idx); setHistoryIdx(null); setInput(queueRef.current[idx] ?? '') } else if (historyIdx !== null) { const next = historyIdx + 1 - if (next >= historyRef.current.length) { - setHistoryIdx(null) - setInput(historyDraftRef.current) - } else { - setHistoryIdx(next) - setInput(historyRef.current[next] ?? '') - } + if (next >= historyRef.current.length) { setHistoryIdx(null); setInput(historyDraftRef.current) } + else { setHistoryIdx(next); setInput(historyRef.current[next] ?? '') } } return } - if (key.ctrl && ch === 'c') { + if (ctrl(key, ch, 'c')) { if (busy && sid) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) const partial = (streaming || buf.current).trimStart() + partial ? appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) : sys('interrupted') - if (partial) { - appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) - } else { - sys('interrupted') - } + idle(); setReasoning(''); setActivity([]); turnToolsRef.current = []; setStatus('interrupted') - idle() - setReasoning('') - setActivity([]) - turnToolsRef.current = [] - setStatus('interrupted') - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - setStatus('ready') - }, 1500) + if (statusTimerRef.current) {clearTimeout(statusTimerRef.current)} + statusTimerRef.current = setTimeout(() => { statusTimerRef.current = null; setStatus('ready') }, 1500) } else if (input || inputBuf.length) { clearIn() } else { @@ -891,26 +859,13 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (key.ctrl && ch === 'd') { - die() - } + if (ctrl(key, ch, 'd')) {return die()} - if (key.ctrl && ch === 'l') { - setStatus('forging session…') - newSession() + if (ctrl(key, ch, 'l')) { setStatus('forging session…'); newSession(); - return - } + return } - if (key.ctrl && ch === 'v') { - paste() - - return - } - - if (key.ctrl && ch === 'g') { - return openEditor() - } + if (ctrl(key, ch, 'g')) {return openEditor()} }) // ── Gateway events ─────────────────────────────────────────────── diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 54dcb17ebb..6ec5cc5fca 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -4,13 +4,9 @@ import { useEffect, useRef, useState } from 'react' function wordLeft(s: string, p: number) { let i = p - 1 - while (i > 0 && /\s/.test(s[i]!)) { - i-- - } + while (i > 0 && /\s/.test(s[i]!)) {i--} - while (i > 0 && !/\s/.test(s[i - 1]!)) { - i-- - } + while (i > 0 && !/\s/.test(s[i - 1]!)) {i--} return Math.max(0, i) } @@ -18,13 +14,9 @@ function wordLeft(s: string, p: number) { function wordRight(s: string, p: number) { let i = p - while (i < s.length && !/\s/.test(s[i]!)) { - i++ - } + while (i < s.length && !/\s/.test(s[i]!)) {i++} - while (i < s.length && /\s/.test(s[i]!)) { - i++ - } + while (i < s.length && /\s/.test(s[i]!)) {i++} return i } @@ -35,13 +27,21 @@ const INV_OFF = ESC + '[27m' const DIM = ESC + '[2m' const DIM_OFF = ESC + '[22m' const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ -const BRACKET_PASTE = new RegExp(`${ESC}\\[20[01]~`, 'g') +const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') + +export interface PasteEvent { + bracketed?: boolean + cursor: number + hotkey?: boolean + text: string + value: string +} interface Props { value: string onChange: (v: string) => void onSubmit?: (v: string) => void - onPaste?: (data: { cursor: number; text: string; value: string }) => { cursor: number; value: string } | null + onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null placeholder?: string focus?: boolean } @@ -77,16 +77,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } }, [value]) - useEffect( - () => () => { - if (pasteTimer.current) { - clearTimeout(pasteTimer.current) - } - }, - [] - ) + useEffect(() => () => { if (pasteTimer.current) {clearTimeout(pasteTimer.current)} }, []) - // ── Buffer ops (synchronous, ref-based — no stale closures) ───── + // ── Buffer ops (synchronous, ref-based) ───────────────────────── const commit = (next: string, nextCur: number, track = true) => { const prev = vRef.current @@ -95,10 +88,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' if (track && next !== prev) { undoStack.current.push({ cursor: curRef.current, value: prev }) - if (undoStack.current.length > 200) { - undoStack.current.shift() - } - + if (undoStack.current.length > 200) {undoStack.current.shift()} redoStack.current = [] } @@ -115,59 +105,58 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' const swap = (from: typeof undoStack, to: typeof redoStack) => { const entry = from.current.pop() - if (!entry) { - return - } - + if (!entry) {return} to.current.push({ cursor: curRef.current, value: vRef.current }) commit(entry.value, entry.cursor, false) } + const emitPaste = (e: PasteEvent) => { + const handled = onPasteRef.current?.(e) + + if (handled) {commit(handled.value, handled.cursor)} + + return !!handled + } + const flushPaste = () => { - const pasted = pasteBuf.current + const text = pasteBuf.current const at = pastePos.current pasteBuf.current = '' pasteTimer.current = null - if (!pasted) { - return - } + if (!text) {return} - const v = vRef.current - const handled = onPasteRef.current?.({ cursor: at, text: pasted, value: v }) - - if (handled) { - return commit(handled.value, handled.cursor) - } - - if (PRINTABLE.test(pasted)) { - commit(v.slice(0, at) + pasted + v.slice(at), at + pasted.length) + if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) { + commit(vRef.current.slice(0, at) + text + vRef.current.slice(at), at + text.length) } } - // ── Input handler (reads only from refs) ──────────────────────── + const insert = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c) + + // ── Input handler ─────────────────────────────────────────────── useInput( (inp, k) => { - if ( - k.upArrow || - k.downArrow || - (k.ctrl && inp === 'c') || - k.tab || - (k.shift && k.tab) || - k.pageUp || - k.pageDown || - k.escape - ) { + // Paste hotkeys — single owner, no competing listeners in App + if ((k.ctrl || k.meta) && inp.toLowerCase() === 'v') { + emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) + return } + // Keys handled by App.useInput + if ( + k.upArrow || k.downArrow || + (k.ctrl && inp === 'c') || + k.tab || (k.shift && k.tab) || + k.pageUp || k.pageDown || + k.escape + ) {return} + if (k.return) { - if (k.shift || k.meta) { - commit(vRef.current.slice(0, curRef.current) + '\n' + vRef.current.slice(curRef.current), curRef.current + 1) - } else { - onSubmitRef.current?.(vRef.current) - } + ;(k.shift || k.meta) + ? commit(insert(vRef.current, curRef.current, '\n'), curRef.current + 1) + : onSubmitRef.current?.(vRef.current) return } @@ -176,78 +165,46 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' let v = vRef.current const mod = k.ctrl || k.meta - if (k.ctrl && inp === 'z') { - return swap(undoStack, redoStack) - } + if (k.ctrl && inp === 'z') {return swap(undoStack, redoStack)} - if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { - return swap(redoStack, undoStack) - } + if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) {return swap(redoStack, undoStack)} - if (k.home || (k.ctrl && inp === 'a')) { - c = 0 - } else if (k.end || (k.ctrl && inp === 'e')) { - c = v.length - } else if (k.leftArrow) { - c = mod ? wordLeft(v, c) : Math.max(0, c - 1) - } else if (k.rightArrow) { - c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) - } else if ((k.backspace || k.delete) && c > 0) { - if (mod) { - const t = wordLeft(v, c) - v = v.slice(0, t) + v.slice(c) - c = t - } else { - v = v.slice(0, c - 1) + v.slice(c) - c-- - } - } else if (k.ctrl && inp === 'w' && c > 0) { - const t = wordLeft(v, c) - v = v.slice(0, t) + v.slice(c) - c = t - } else if (k.ctrl && inp === 'u') { - v = v.slice(c) - c = 0 - } else if (k.ctrl && inp === 'k') { - v = v.slice(0, c) - } else if (k.meta && inp === 'b') { - c = wordLeft(v, c) - } else if (k.meta && inp === 'f') { - c = wordRight(v, c) - } else if (inp.length > 0) { + if (k.home || (k.ctrl && inp === 'a')) {c = 0} + else if (k.end || (k.ctrl && inp === 'e')) {c = v.length} + else if (k.leftArrow) {c = mod ? wordLeft(v, c) : Math.max(0, c - 1)} + else if (k.rightArrow) {c = mod ? wordRight(v, c) : Math.min(v.length, c + 1)} + else if ((k.backspace || k.delete) && c > 0) { + if (mod) { const t = wordLeft(v, c); v = v.slice(0, t) + v.slice(c); c = t } + else { v = v.slice(0, c - 1) + v.slice(c); c-- } + } + else if (k.ctrl && inp === 'w' && c > 0) { const t = wordLeft(v, c); v = v.slice(0, t) + v.slice(c); c = t } + else if (k.ctrl && inp === 'u') { v = v.slice(c); c = 0 } + else if (k.ctrl && inp === 'k') {v = v.slice(0, c)} + else if (k.meta && inp === 'b') {c = wordLeft(v, c)} + else if (k.meta && inp === 'f') {c = wordRight(v, c)} + else if (inp.length > 0) { + const bracketed = inp.includes('[200~') const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') - if (!raw) { - return - } + if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {return} - if (raw === '\n') { - return commit(v.slice(0, c) + '\n' + v.slice(c), c + 1) - } + if (!raw) {return} + + if (raw === '\n') {return commit(insert(v, c, '\n'), c + 1)} if (raw.length > 1 || raw.includes('\n')) { - if (!pasteBuf.current) { - pastePos.current = c - } + if (!pasteBuf.current) {pastePos.current = c} pasteBuf.current += raw - if (pasteTimer.current) { - clearTimeout(pasteTimer.current) - } + if (pasteTimer.current) {clearTimeout(pasteTimer.current)} pasteTimer.current = setTimeout(flushPaste, 50) return } - if (PRINTABLE.test(raw)) { - v = v.slice(0, c) + raw + v.slice(c) - c += raw.length - } else { - return - } - } else { - return - } + if (PRINTABLE.test(raw)) { v = v.slice(0, c) + raw + v.slice(c); c += raw.length } + else {return} + } else {return} commit(v, c) }, @@ -256,17 +213,16 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' // ── Render ────────────────────────────────────────────────────── - if (!focus) { - return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} - } + if (!focus) {return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')}} if (!value && placeholder) { return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} } - const rendered = - [...value].map((ch, i) => (i === cur ? INV + ch + INV_OFF : ch)).join('') + - (cur === value.length ? INV + ' ' + INV_OFF : '') - - return {rendered} + return ( + + {[...value].map((ch, i) => (i === cur ? INV + ch + INV_OFF : ch)).join('') + + (cur === value.length ? INV + ' ' + INV_OFF : '')} + + ) } diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 36dd999e69..c244e6b584 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -24,7 +24,7 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+D', 'exit'], ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'new session (clear)'], - ['Ctrl+V', 'paste clipboard image'], + ['Ctrl+V / Alt+V', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], ['Ctrl+A/E', 'home / end of line'], From 17f13013ebabed4483fe308a2d5e818e2c5f5319 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 14:17:45 -0500 Subject: [PATCH 039/157] chore: fmt --- ui-tui/src/app.tsx | 129 +++++++++++++++++------- ui-tui/src/components/textInput.tsx | 151 ++++++++++++++++++++-------- 2 files changed, 206 insertions(+), 74 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index eae4043714..2efaca53ea 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -13,7 +13,7 @@ import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' -import { TextInput } from './components/textInput.js' +import { type PasteEvent, TextInput } from './components/textInput.js' import { Thinking } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' @@ -229,18 +229,24 @@ export function App({ gw }: { gw: GatewayClient }) { const [cols, setCols] = useState(stdout?.columns ?? 80) useEffect(() => { - if (!stdout) {return} + if (!stdout) { + return + } const sync = () => setCols(stdout.columns ?? 80) stdout.on('resize', sync) // Enable bracketed paste so image-only clipboard paste reaches the app - if (stdout.isTTY) {stdout.write('\x1b[?2004h')} + if (stdout.isTTY) { + stdout.write('\x1b[?2004h') + } return () => { stdout.off('resize', sync) - if (stdout.isTTY) {stdout.write('\x1b[?2004l')} + if (stdout.isTTY) { + stdout.write('\x1b[?2004l') + } } }, [stdout]) @@ -513,14 +519,20 @@ export function App({ gw }: { gw: GatewayClient }) { ) const handleTextPaste = useCallback( - ({ bracketed, cursor, hotkey, text, value }: import('./components/textInput.js').PasteEvent) => { - if (hotkey) { void paste(false); + ({ bracketed, cursor, hotkey, text, value }: PasteEvent) => { + if (hotkey) { + void paste(false) - return null } + return null + } - if (bracketed) {void paste(true)} + if (bracketed) { + void paste(true) + } - if (!text) {return null} + if (!text) { + return null + } const lineCount = text.split('\n').length @@ -770,23 +782,38 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Input handling ─────────────────────────────────────────────── - const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => - key.ctrl && ch.toLowerCase() === target + const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target useInput((ch, key) => { if (isBlocked) { if (pasteReview) { - if (key.return) { setPasteReview(null); dispatchSubmission(pasteReview.text, true) } - else if (key.escape || ctrl(key, ch, 'c')) { setPasteReview(null); setStatus('ready') } + if (key.return) { + setPasteReview(null) + dispatchSubmission(pasteReview.text, true) + } else if (key.escape || ctrl(key, ch, 'c')) { + setPasteReview(null) + setStatus('ready') + } return } if (ctrl(key, ch, 'c')) { - if (approval) { gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}); setApproval(null); sys('denied') } - else if (sudo) { gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}); setSudo(null); sys('sudo cancelled') } - else if (secret) { gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}); setSecret(null); sys('secret entry cancelled') } - else if (picker) {setPicker(false)} + if (approval) { + gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) + setApproval(null) + sys('denied') + } else if (sudo) { + gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) + setSudo(null) + sys('sudo cancelled') + } else if (secret) { + gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) + setSecret(null) + sys('secret entry cancelled') + } else if (picker) { + setPicker(false) + } } else if (key.escape && picker) { setPicker(false) } @@ -803,7 +830,9 @@ export function App({ gw }: { gw: GatewayClient }) { if (!inputBuf.length && key.tab && completions.length) { const row = completions[compIdx] - if (row) {setInput(input.slice(0, compReplace) + row.text)} + if (row) { + setInput(input.slice(0, compReplace) + row.text) + } return } @@ -811,12 +840,19 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.upArrow && !inputBuf.length) { if (queueRef.current.length) { const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % queueRef.current.length - setQueueEdit(idx); setHistoryIdx(null); setInput(queueRef.current[idx] ?? '') + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') } else if (historyRef.current.length) { const idx = historyIdx === null ? historyRef.current.length - 1 : Math.max(0, historyIdx - 1) - if (historyIdx === null) {historyDraftRef.current = input} - setHistoryIdx(idx); setQueueEdit(null); setInput(historyRef.current[idx] ?? '') + if (historyIdx === null) { + historyDraftRef.current = input + } + + setHistoryIdx(idx) + setQueueEdit(null) + setInput(historyRef.current[idx] ?? '') } return @@ -824,16 +860,24 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.downArrow && !inputBuf.length) { if (queueRef.current.length) { - const idx = queueEditIdx === null - ? queueRef.current.length - 1 - : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length + const idx = + queueEditIdx === null + ? queueRef.current.length - 1 + : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length - setQueueEdit(idx); setHistoryIdx(null); setInput(queueRef.current[idx] ?? '') + setQueueEdit(idx) + setHistoryIdx(null) + setInput(queueRef.current[idx] ?? '') } else if (historyIdx !== null) { const next = historyIdx + 1 - if (next >= historyRef.current.length) { setHistoryIdx(null); setInput(historyDraftRef.current) } - else { setHistoryIdx(next); setInput(historyRef.current[next] ?? '') } + if (next >= historyRef.current.length) { + setHistoryIdx(null) + setInput(historyDraftRef.current) + } else { + setHistoryIdx(next) + setInput(historyRef.current[next] ?? '') + } } return @@ -846,10 +890,20 @@ export function App({ gw }: { gw: GatewayClient }) { const partial = (streaming || buf.current).trimStart() partial ? appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) : sys('interrupted') - idle(); setReasoning(''); setActivity([]); turnToolsRef.current = []; setStatus('interrupted') + idle() + setReasoning('') + setActivity([]) + turnToolsRef.current = [] + setStatus('interrupted') - if (statusTimerRef.current) {clearTimeout(statusTimerRef.current)} - statusTimerRef.current = setTimeout(() => { statusTimerRef.current = null; setStatus('ready') }, 1500) + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus('ready') + }, 1500) } else if (input || inputBuf.length) { clearIn() } else { @@ -859,13 +913,20 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (ctrl(key, ch, 'd')) {return die()} + if (ctrl(key, ch, 'd')) { + return die() + } - if (ctrl(key, ch, 'l')) { setStatus('forging session…'); newSession(); + if (ctrl(key, ch, 'l')) { + setStatus('forging session…') + newSession() - return } + return + } - if (ctrl(key, ch, 'g')) {return openEditor()} + if (ctrl(key, ch, 'g')) { + return openEditor() + } }) // ── Gateway events ─────────────────────────────────────────────── diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 6ec5cc5fca..9ec083c9dd 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -4,9 +4,13 @@ import { useEffect, useRef, useState } from 'react' function wordLeft(s: string, p: number) { let i = p - 1 - while (i > 0 && /\s/.test(s[i]!)) {i--} + while (i > 0 && /\s/.test(s[i]!)) { + i-- + } - while (i > 0 && !/\s/.test(s[i - 1]!)) {i--} + while (i > 0 && !/\s/.test(s[i - 1]!)) { + i-- + } return Math.max(0, i) } @@ -14,9 +18,13 @@ function wordLeft(s: string, p: number) { function wordRight(s: string, p: number) { let i = p - while (i < s.length && !/\s/.test(s[i]!)) {i++} + while (i < s.length && !/\s/.test(s[i]!)) { + i++ + } - while (i < s.length && /\s/.test(s[i]!)) {i++} + while (i < s.length && /\s/.test(s[i]!)) { + i++ + } return i } @@ -77,7 +85,14 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } }, [value]) - useEffect(() => () => { if (pasteTimer.current) {clearTimeout(pasteTimer.current)} }, []) + useEffect( + () => () => { + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } + }, + [] + ) // ── Buffer ops (synchronous, ref-based) ───────────────────────── @@ -88,7 +103,10 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' if (track && next !== prev) { undoStack.current.push({ cursor: curRef.current, value: prev }) - if (undoStack.current.length > 200) {undoStack.current.shift()} + if (undoStack.current.length > 200) { + undoStack.current.shift() + } + redoStack.current = [] } @@ -105,7 +123,10 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' const swap = (from: typeof undoStack, to: typeof redoStack) => { const entry = from.current.pop() - if (!entry) {return} + if (!entry) { + return + } + to.current.push({ cursor: curRef.current, value: vRef.current }) commit(entry.value, entry.cursor, false) } @@ -113,7 +134,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' const emitPaste = (e: PasteEvent) => { const handled = onPasteRef.current?.(e) - if (handled) {commit(handled.value, handled.cursor)} + if (handled) { + commit(handled.value, handled.cursor) + } return !!handled } @@ -124,7 +147,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' pasteBuf.current = '' pasteTimer.current = null - if (!text) {return} + if (!text) { + return + } if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) { commit(vRef.current.slice(0, at) + text + vRef.current.slice(at), at + text.length) @@ -146,15 +171,20 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' // Keys handled by App.useInput if ( - k.upArrow || k.downArrow || + k.upArrow || + k.downArrow || (k.ctrl && inp === 'c') || - k.tab || (k.shift && k.tab) || - k.pageUp || k.pageDown || + k.tab || + (k.shift && k.tab) || + k.pageUp || + k.pageDown || k.escape - ) {return} + ) { + return + } if (k.return) { - ;(k.shift || k.meta) + k.shift || k.meta ? commit(insert(vRef.current, curRef.current, '\n'), curRef.current + 1) : onSubmitRef.current?.(vRef.current) @@ -165,46 +195,85 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' let v = vRef.current const mod = k.ctrl || k.meta - if (k.ctrl && inp === 'z') {return swap(undoStack, redoStack)} - - if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) {return swap(redoStack, undoStack)} - - if (k.home || (k.ctrl && inp === 'a')) {c = 0} - else if (k.end || (k.ctrl && inp === 'e')) {c = v.length} - else if (k.leftArrow) {c = mod ? wordLeft(v, c) : Math.max(0, c - 1)} - else if (k.rightArrow) {c = mod ? wordRight(v, c) : Math.min(v.length, c + 1)} - else if ((k.backspace || k.delete) && c > 0) { - if (mod) { const t = wordLeft(v, c); v = v.slice(0, t) + v.slice(c); c = t } - else { v = v.slice(0, c - 1) + v.slice(c); c-- } + if (k.ctrl && inp === 'z') { + return swap(undoStack, redoStack) } - else if (k.ctrl && inp === 'w' && c > 0) { const t = wordLeft(v, c); v = v.slice(0, t) + v.slice(c); c = t } - else if (k.ctrl && inp === 'u') { v = v.slice(c); c = 0 } - else if (k.ctrl && inp === 'k') {v = v.slice(0, c)} - else if (k.meta && inp === 'b') {c = wordLeft(v, c)} - else if (k.meta && inp === 'f') {c = wordRight(v, c)} - else if (inp.length > 0) { + + if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { + return swap(redoStack, undoStack) + } + + if (k.home || (k.ctrl && inp === 'a')) { + c = 0 + } else if (k.end || (k.ctrl && inp === 'e')) { + c = v.length + } else if (k.leftArrow) { + c = mod ? wordLeft(v, c) : Math.max(0, c - 1) + } else if (k.rightArrow) { + c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) + } else if ((k.backspace || k.delete) && c > 0) { + if (mod) { + const t = wordLeft(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else { + v = v.slice(0, c - 1) + v.slice(c) + c-- + } + } else if (k.ctrl && inp === 'w' && c > 0) { + const t = wordLeft(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else if (k.ctrl && inp === 'u') { + v = v.slice(c) + c = 0 + } else if (k.ctrl && inp === 'k') { + v = v.slice(0, c) + } else if (k.meta && inp === 'b') { + c = wordLeft(v, c) + } else if (k.meta && inp === 'f') { + c = wordRight(v, c) + } else if (inp.length > 0) { const bracketed = inp.includes('[200~') const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') - if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) {return} + if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) { + return + } - if (!raw) {return} + if (!raw) { + return + } - if (raw === '\n') {return commit(insert(v, c, '\n'), c + 1)} + if (raw === '\n') { + return commit(insert(v, c, '\n'), c + 1) + } if (raw.length > 1 || raw.includes('\n')) { - if (!pasteBuf.current) {pastePos.current = c} + if (!pasteBuf.current) { + pastePos.current = c + } + pasteBuf.current += raw - if (pasteTimer.current) {clearTimeout(pasteTimer.current)} + if (pasteTimer.current) { + clearTimeout(pasteTimer.current) + } + pasteTimer.current = setTimeout(flushPaste, 50) return } - if (PRINTABLE.test(raw)) { v = v.slice(0, c) + raw + v.slice(c); c += raw.length } - else {return} - } else {return} + if (PRINTABLE.test(raw)) { + v = v.slice(0, c) + raw + v.slice(c) + c += raw.length + } else { + return + } + } else { + return + } commit(v, c) }, @@ -213,7 +282,9 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' // ── Render ────────────────────────────────────────────────────── - if (!focus) {return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')}} + if (!focus) { + return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} + } if (!value && placeholder) { return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} From 670dcea8f44a8884f67301244a6cec516b3025c7 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:18 -0400 Subject: [PATCH 040/157] ui-tui: add tsc build pipeline - Switch tsconfig to nodenext module resolution for Node 22 (used by installer script) - Add shebang to entry.tsx, preserved into index.js - Add HERMES_ROOT env var fallback for repo root resolution --- ui-tui/package.json | 2 +- ui-tui/src/entry.tsx | 1 + ui-tui/src/gatewayClient.ts | 2 +- ui-tui/tsconfig.json | 19 +++++++++++-------- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/ui-tui/package.json b/ui-tui/package.json index 5100edbd21..177cdd05a0 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", - "build": "tsc", + "build": "tsc && chmod +x dist/entry.js", "lint": "eslint src/", "lint:fix": "eslint src/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}'", diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 29d0349573..68dc9c0b72 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,3 +1,4 @@ +#!/usr/bin/env node import { render } from 'ink' import React from 'react' diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 3326109613..5a3eac5e82 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -24,7 +24,7 @@ export class GatewayClient extends EventEmitter { private pending = new Map() start() { - const root = resolve(import.meta.dirname, '../../') + const root = process.env.HERMES_ROOT ?? resolve(import.meta.dirname, '../../') this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], { cwd: root, diff --git a/ui-tui/tsconfig.json b/ui-tui/tsconfig.json index fe135bfecb..b7817e13a6 100644 --- a/ui-tui/tsconfig.json +++ b/ui-tui/tsconfig.json @@ -1,16 +1,19 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "bundler", + "module": "nodenext", + "moduleResolution": "nodenext", "jsx": "react-jsx", - "lib": ["ES2022"], - "types": ["node"], - "strict": true, - "esModuleInterop": true, "outDir": "dist", "rootDir": "src", - "skipLibCheck": true + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": false, + "sourceMap": false, + "resolveJsonModule": true }, - "include": ["src"] + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/__tests__", "node_modules", "dist"] } From 2b4272ef5b45fd7eef5710d4fd97b6ab740f1eb4 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:29 -0400 Subject: [PATCH 041/157] ui-tui: update package-lock.json --- ui-tui/package-lock.json | 1893 ++++++++++++++++++++++++++++++-------- 1 file changed, 1497 insertions(+), 396 deletions(-) diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 03c9c33976..18a63d6880 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -33,6 +33,8 @@ }, "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", @@ -42,8 +44,22 @@ "node": ">=18" } }, + "node_modules/@alcalzone/ansi-tokenize/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/@babel/code-frame": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -57,6 +73,8 @@ }, "node_modules/@babel/compat-data": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -65,6 +83,8 @@ }, "node_modules/@babel/core": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", "dependencies": { @@ -94,6 +114,8 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -102,6 +124,8 @@ }, "node_modules/@babel/generator": { "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { @@ -117,6 +141,8 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { @@ -132,6 +158,8 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -140,6 +168,8 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", "engines": { @@ -148,6 +178,8 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { @@ -160,6 +192,8 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { @@ -176,6 +210,8 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -184,6 +220,8 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -192,6 +230,8 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -200,6 +240,8 @@ }, "node_modules/@babel/helpers": { "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { @@ -212,6 +254,8 @@ }, "node_modules/@babel/parser": { "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -226,6 +270,8 @@ }, "node_modules/@babel/template": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { @@ -239,6 +285,8 @@ }, "node_modules/@babel/traverse": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { @@ -256,6 +304,8 @@ }, "node_modules/@babel/types": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -267,21 +317,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "dev": true, "license": "MIT", "optional": true, @@ -290,9 +340,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -300,8 +350,282 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.5", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -315,8 +639,163 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -332,19 +811,10 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -353,6 +823,8 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -364,8 +836,28 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -375,22 +867,10 @@ "node": "*" } }, - "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -402,6 +882,8 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -413,6 +895,8 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { @@ -433,8 +917,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -446,6 +950,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -454,6 +960,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -463,22 +971,10 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -490,6 +986,8 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -498,6 +996,8 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -510,6 +1010,8 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -518,6 +1020,8 @@ }, "node_modules/@humanfs/node": { "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -530,6 +1034,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -542,6 +1048,8 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -554,6 +1062,8 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -563,6 +1073,8 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -572,6 +1084,8 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { @@ -580,11 +1094,15 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -612,9 +1130,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", - "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { @@ -622,9 +1140,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", - "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", "cpu": [ "arm64" ], @@ -639,9 +1157,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", - "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", "cpu": [ "arm64" ], @@ -656,9 +1174,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", - "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", "cpu": [ "x64" ], @@ -673,9 +1191,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", - "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", "cpu": [ "x64" ], @@ -690,9 +1208,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", - "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", "cpu": [ "arm" ], @@ -707,9 +1225,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", "cpu": [ "arm64" ], @@ -724,9 +1242,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", - "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", "cpu": [ "arm64" ], @@ -741,9 +1259,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", "cpu": [ "ppc64" ], @@ -758,9 +1276,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", "cpu": [ "s390x" ], @@ -775,9 +1293,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], @@ -792,9 +1310,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", - "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], @@ -809,9 +1327,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", - "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", "cpu": [ "arm64" ], @@ -826,9 +1344,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", - "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", "cpu": [ "wasm32" ], @@ -836,18 +1354,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "1.9.1", - "@emnapi/runtime": "1.9.1", - "@napi-rs/wasm-runtime": "^1.1.2" + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", - "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", "cpu": [ "arm64" ], @@ -862,9 +1380,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", - "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", "cpu": [ "x64" ], @@ -879,9 +1397,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", - "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -923,16 +1441,22 @@ }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", "dev": true, "license": "MIT", "dependencies": { @@ -941,6 +1465,8 @@ }, "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==", "devOptional": true, "license": "MIT", "dependencies": { @@ -948,15 +1474,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/type-utils": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -969,20 +1497,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.0", + "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "engines": { @@ -998,12 +1528,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "engines": { @@ -1018,12 +1550,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1034,7 +1568,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", "dev": true, "license": "MIT", "engines": { @@ -1049,13 +1585,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1072,7 +1610,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", "dev": true, "license": "MIT", "engines": { @@ -1084,14 +1624,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1109,59 +1651,17 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.5", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.4", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0" + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1176,11 +1676,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1193,6 +1695,8 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1203,16 +1707,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", - "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1221,13 +1725,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", - "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.3", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1248,9 +1752,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", - "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -1261,13 +1765,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", - "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.3", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -1275,14 +1779,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", - "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1291,9 +1795,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", - "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -1301,13 +1805,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", - "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1317,6 +1821,8 @@ }, "node_modules/acorn": { "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1328,6 +1834,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1336,6 +1844,8 @@ }, "node_modules/ajv": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1351,6 +1861,8 @@ }, "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" @@ -1364,6 +1876,8 @@ }, "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" @@ -1373,10 +1887,16 @@ } }, "node_modules/ansi-styles": { - "version": "6.2.3", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -1384,11 +1904,15 @@ }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -1404,6 +1928,8 @@ }, "node_modules/array-includes": { "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1425,6 +1951,8 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1444,6 +1972,8 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -1461,6 +1991,8 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -1478,6 +2010,8 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -1493,6 +2027,8 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1523,6 +2059,8 @@ }, "node_modules/async-function": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", "engines": { @@ -1531,6 +2069,8 @@ }, "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" @@ -1541,6 +2081,8 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1553,8 +2095,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1564,8 +2118,23 @@ "node": ">=6.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1597,13 +2166,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -1615,6 +2186,8 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1627,6 +2200,8 @@ }, "node_modules/call-bound": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { @@ -1642,6 +2217,8 @@ }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -1649,7 +2226,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", "dev": true, "funding": [ { @@ -1677,8 +2256,27 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "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" @@ -1689,6 +2287,8 @@ }, "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" @@ -1702,6 +2302,8 @@ }, "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", @@ -1714,22 +2316,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "8.2.0", - "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" @@ -1740,6 +2330,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1751,21 +2343,29 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "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" @@ -1773,6 +2373,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1786,11 +2388,15 @@ }, "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==", "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1807,6 +2413,8 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1823,6 +2431,8 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1839,6 +2449,8 @@ }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1855,11 +2467,15 @@ }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/define-data-property": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1876,6 +2492,8 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -1902,6 +2520,8 @@ }, "node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1913,6 +2533,8 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1925,16 +2547,22 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.331", + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", "dev": true, "license": "ISC" }, "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" @@ -1944,7 +2572,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -2012,6 +2642,8 @@ }, "node_modules/es-define-property": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { @@ -2020,6 +2652,8 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", "engines": { @@ -2028,6 +2662,8 @@ }, "node_modules/es-iterator-helpers": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2062,6 +2698,8 @@ }, "node_modules/es-object-atoms": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -2073,6 +2711,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { @@ -2087,6 +2727,8 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -2098,6 +2740,8 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { @@ -2114,6 +2758,8 @@ }, "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", @@ -2121,7 +2767,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.5", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2132,44 +2780,61 @@ "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" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint": { "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2228,6 +2893,8 @@ }, "node_modules/eslint-plugin-perfectionist": { "version": "5.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.8.0.tgz", + "integrity": "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==", "dev": true, "license": "MIT", "dependencies": { @@ -2243,6 +2910,8 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -2274,6 +2943,8 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2290,8 +2961,28 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-react/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2301,22 +2992,10 @@ "node": "*" } }, - "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.13", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "1.0.2", - "dev": true, - "license": "MIT" - }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -2325,6 +3004,8 @@ }, "node_modules/eslint-plugin-unused-imports": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2339,6 +3020,8 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2352,27 +3035,30 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2380,34 +3066,10 @@ "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2419,6 +3081,8 @@ }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -2427,6 +3091,8 @@ }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2438,6 +3104,8 @@ }, "node_modules/espree": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2454,6 +3122,8 @@ }, "node_modules/espree/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2465,6 +3135,8 @@ }, "node_modules/esquery": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2476,6 +3148,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2487,6 +3161,8 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2505,6 +3181,8 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2523,21 +3201,29 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -2554,6 +3240,8 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2565,6 +3253,8 @@ }, "node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -2580,6 +3270,8 @@ }, "node_modules/flat-cache": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -2592,11 +3284,15 @@ }, "node_modules/flatted": { "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2626,6 +3322,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -2634,6 +3332,8 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2653,6 +3353,8 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { @@ -2661,6 +3363,8 @@ }, "node_modules/generator-function": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "dev": true, "license": "MIT", "engines": { @@ -2669,6 +3373,8 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { @@ -2677,6 +3383,8 @@ }, "node_modules/get-east-asian-width": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -2687,6 +3395,8 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2710,6 +3420,8 @@ }, "node_modules/get-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2722,6 +3434,8 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { @@ -2738,6 +3452,8 @@ }, "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": { @@ -2749,6 +3465,8 @@ }, "node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -2760,6 +3478,8 @@ }, "node_modules/globals": { "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -2771,6 +3491,8 @@ }, "node_modules/globalthis": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2786,6 +3508,8 @@ }, "node_modules/gopd": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", "engines": { @@ -2797,6 +3521,8 @@ }, "node_modules/has-bigints": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -2808,6 +3534,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -2816,6 +3544,8 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -2827,6 +3557,8 @@ }, "node_modules/has-proto": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2841,6 +3573,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -2852,6 +3586,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { @@ -2866,6 +3602,8 @@ }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2877,11 +3615,15 @@ }, "node_modules/hermes-estree": { "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", "dev": true, "license": "MIT" }, "node_modules/hermes-parser": { "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", "dev": true, "license": "MIT", "dependencies": { @@ -2890,6 +3632,8 @@ }, "node_modules/ignore": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -2898,6 +3642,8 @@ }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2913,6 +3659,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -2921,6 +3669,8 @@ }, "node_modules/indent-string": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "license": "MIT", "engines": { "node": ">=12" @@ -2931,6 +3681,8 @@ }, "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", @@ -2978,6 +3730,8 @@ }, "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", @@ -2993,6 +3747,8 @@ }, "node_modules/ink-text-input/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" @@ -3003,6 +3759,8 @@ }, "node_modules/ink-text-input/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" @@ -3011,8 +3769,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ink/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/ink/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" @@ -3021,35 +3793,10 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/ink/node_modules/string-width": { - "version": "8.2.0", - "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", - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/internal-slot": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -3063,6 +3810,8 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -3079,6 +3828,8 @@ }, "node_modules/is-async-function": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3097,6 +3848,8 @@ }, "node_modules/is-bigint": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3111,6 +3864,8 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -3126,6 +3881,8 @@ }, "node_modules/is-callable": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -3137,6 +3894,8 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -3151,6 +3910,8 @@ }, "node_modules/is-data-view": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { @@ -3167,6 +3928,8 @@ }, "node_modules/is-date-object": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -3182,6 +3945,8 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -3190,6 +3955,8 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { @@ -3204,6 +3971,8 @@ }, "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" @@ -3217,6 +3986,8 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { @@ -3235,6 +4006,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -3246,6 +4019,8 @@ }, "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" @@ -3259,6 +4034,8 @@ }, "node_modules/is-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -3270,6 +4047,8 @@ }, "node_modules/is-negative-zero": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { @@ -3281,6 +4060,8 @@ }, "node_modules/is-number-object": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -3296,6 +4077,8 @@ }, "node_modules/is-regex": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -3313,6 +4096,8 @@ }, "node_modules/is-set": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -3324,6 +4109,8 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -3338,6 +4125,8 @@ }, "node_modules/is-string": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -3353,6 +4142,8 @@ }, "node_modules/is-symbol": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3369,6 +4160,8 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3383,6 +4176,8 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -3394,6 +4189,8 @@ }, "node_modules/is-weakref": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { @@ -3408,6 +4205,8 @@ }, "node_modules/is-weakset": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3423,16 +4222,22 @@ }, "node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -3449,11 +4254,15 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3465,6 +4274,8 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -3476,21 +4287,29 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", "bin": { @@ -3502,6 +4321,8 @@ }, "node_modules/jsx-ast-utils": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3516,6 +4337,8 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -3524,6 +4347,8 @@ }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3797,6 +4622,8 @@ }, "node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -3811,11 +4638,15 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3827,6 +4658,8 @@ }, "node_modules/lru-cache": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -3845,6 +4678,8 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { @@ -3853,13 +4688,33 @@ }, "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/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, @@ -3884,11 +4739,15 @@ }, "node_modules/natural-compare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/natural-orderby": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", "dev": true, "license": "MIT", "engines": { @@ -3897,6 +4756,8 @@ }, "node_modules/node-exports-info": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3914,6 +4775,8 @@ }, "node_modules/node-exports-info/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -3922,11 +4785,15 @@ }, "node_modules/node-releases": { "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", "engines": { @@ -3935,6 +4802,8 @@ }, "node_modules/object-inspect": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -3946,6 +4815,8 @@ }, "node_modules/object-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -3954,6 +4825,8 @@ }, "node_modules/object.assign": { "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -3973,6 +4846,8 @@ }, "node_modules/object.entries": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { @@ -3987,6 +4862,8 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4004,6 +4881,8 @@ }, "node_modules/object.values": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -4032,6 +4911,8 @@ }, "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" @@ -4045,6 +4926,8 @@ }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -4061,6 +4944,8 @@ }, "node_modules/own-keys": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, "license": "MIT", "dependencies": { @@ -4077,6 +4962,8 @@ }, "node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4091,6 +4978,8 @@ }, "node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -4105,6 +4994,8 @@ }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -4116,6 +5007,8 @@ }, "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" @@ -4123,6 +5016,8 @@ }, "node_modules/path-exists": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -4131,6 +5026,8 @@ }, "node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -4139,6 +5036,8 @@ }, "node_modules/path-parse": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, "license": "MIT" }, @@ -4151,11 +5050,15 @@ }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -4167,6 +5070,8 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -4204,6 +5109,8 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -4212,6 +5119,8 @@ }, "node_modules/prettier": { "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -4226,6 +5135,8 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, "license": "MIT", "dependencies": { @@ -4236,6 +5147,8 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -4243,7 +5156,9 @@ } }, "node_modules/react": { - "version": "19.2.4", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4251,11 +5166,15 @@ }, "node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true, "license": "MIT" }, "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" @@ -4269,6 +5188,8 @@ }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { @@ -4290,6 +5211,8 @@ }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -4309,6 +5232,8 @@ }, "node_modules/resolve": { "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { @@ -4331,6 +5256,8 @@ }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -4339,6 +5266,8 @@ }, "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": { @@ -4347,6 +5276,8 @@ }, "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", @@ -4360,14 +5291,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", - "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.123.0", - "@rolldown/pluginutils": "1.0.0-rc.13" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4376,25 +5307,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.13", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", - "@rolldown/binding-darwin-x64": "1.0.0-rc.13", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/safe-array-concat": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4413,6 +5346,8 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { @@ -4428,6 +5363,8 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -4444,10 +5381,27 @@ }, "node_modules/scheduler": { "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-function-length": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -4464,6 +5418,8 @@ }, "node_modules/set-function-name": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4478,6 +5434,8 @@ }, "node_modules/set-proto": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { @@ -4491,6 +5449,8 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -4502,6 +5462,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -4510,6 +5472,8 @@ }, "node_modules/side-channel": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { @@ -4527,12 +5491,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4543,6 +5509,8 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { @@ -4560,6 +5528,8 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { @@ -4585,10 +5555,14 @@ }, "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", @@ -4601,6 +5575,18 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/slice-ansi/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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4613,6 +5599,8 @@ }, "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" @@ -4623,6 +5611,8 @@ }, "node_modules/stack-utils/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" @@ -4644,6 +5634,8 @@ }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4654,8 +5646,26 @@ "node": ">= 0.4" } }, + "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/string.prototype.matchall": { "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, "license": "MIT", "dependencies": { @@ -4682,6 +5692,8 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4691,6 +5703,8 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { @@ -4711,6 +5725,8 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4728,6 +5744,8 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { @@ -4744,6 +5762,8 @@ }, "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" @@ -4757,6 +5777,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -4768,6 +5790,8 @@ }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -4779,6 +5803,8 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { @@ -4790,6 +5816,8 @@ }, "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" @@ -4800,6 +5828,8 @@ }, "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" @@ -4826,12 +5856,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4852,6 +5884,8 @@ }, "node_modules/ts-api-utils": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -4871,6 +5905,8 @@ }, "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": { @@ -4889,6 +5925,8 @@ }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -4898,8 +5936,25 @@ "node": ">= 0.8.0" } }, + "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/typed-array-buffer": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { @@ -4913,6 +5968,8 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { @@ -4931,6 +5988,8 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4951,6 +6010,8 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -4970,6 +6031,8 @@ }, "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": { @@ -4982,6 +6045,8 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { @@ -4999,11 +6064,15 @@ }, "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/unicode-animations": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz", + "integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -5015,6 +6084,8 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -5044,6 +6115,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5051,16 +6124,16 @@ } }, "node_modules/vite": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", - "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.13", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { @@ -5129,19 +6202,19 @@ } }, "node_modules/vitest": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", - "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.3", - "@vitest/mocker": "4.1.3", - "@vitest/pretty-format": "4.1.3", - "@vitest/runner": "4.1.3", - "@vitest/snapshot": "4.1.3", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5169,12 +6242,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.3", - "@vitest/browser-preview": "4.1.3", - "@vitest/browser-webdriverio": "4.1.3", - "@vitest/coverage-istanbul": "4.1.3", - "@vitest/coverage-v8": "4.1.3", - "@vitest/ui": "4.1.3", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5220,6 +6293,8 @@ }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -5234,6 +6309,8 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -5252,6 +6329,8 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5278,6 +6357,8 @@ }, "node_modules/which-collection": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -5295,6 +6376,8 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5332,6 +6415,8 @@ }, "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" @@ -5343,22 +6428,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/widest-line/node_modules/string-width": { - "version": "8.2.0", - "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/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -5367,6 +6440,8 @@ }, "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", @@ -5380,8 +6455,22 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/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/wrap-ansi/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -5397,6 +6486,8 @@ }, "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" @@ -5416,11 +6507,15 @@ }, "node_modules/yallist": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, "node_modules/yocto-queue": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { @@ -5432,10 +6527,14 @@ }, "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" }, "node_modules/zod": { "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", "funding": { @@ -5444,6 +6543,8 @@ }, "node_modules/zod-validation-error": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, "license": "MIT", "engines": { From 5ff96551d508fed1efd67be5f6b3285b1259759a Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:29 -0400 Subject: [PATCH 042/157] cli: support bundled TUI at HERMES_TUI_DIR (for nix) - Fix cwd to use bundled TUI dir, not PROJECT_ROOT - Set HERMES_ROOT from env with cwd fallback --- hermes_cli/main.py | 54 +++++++++++++++++++++++++++++++++------------- pyproject.toml | 2 +- 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c838639ba6..58761dcb0c 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -605,15 +605,36 @@ def _print_tui_exit_summary(session_id: Optional[str]) -> None: ) -def _launch_tui(resume_session_id: Optional[str] = None): - """Replace current process with the Ink TUI.""" - tui_dir = PROJECT_ROOT / "ui-tui" +def _find_bundled_tui() -> Optional[Path]: + """Find a bundled copy of the TUI. + Does *not* read from the `npm run build` dist dir, + as this would be a footgun when developing + """ + bundled_tui_dir = os.environ.get("HERMES_TUI_DIR") + if bundled_tui_dir and (Path(bundled_tui_dir) / "dist" / "entry.js").exists(): + return Path(bundled_tui_dir) + return None - if not (tui_dir / "node_modules").exists(): - npm = shutil.which("npm") - if not npm: - print("npm not found — install Node.js to use the TUI.") +def _make_tui_argv(tui_dir: Path) -> tuple[list[str], Path]: + """Gets argv to run tui + the working directory. Will npm install deps in dev mode.""" + def _node_bin(bin: str)-> str: + path = shutil.which(bin) + if not path: + print(f"{bin} not found — install Node.js to use the TUI.") sys.exit(1) + return path + + # use prebuilt TUI if it exists + bundled = _find_bundled_tui() + if bundled: + node = _node_bin("node") + return [node, str(bundled / "dist" / "entry.js")], bundled + + # dev mode - run via tsx + + # install deps if needed + if not (tui_dir / "node_modules").exists(): + npm = _node_bin("npm") print("Installing TUI dependencies…") result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], @@ -632,20 +653,23 @@ def _launch_tui(resume_session_id: Optional[str] = None): tsx = tui_dir / "node_modules" / ".bin" / "tsx" if tsx.exists(): - argv = [str(tsx), "src/entry.tsx"] - else: - npm = shutil.which("npm") - if not npm: - print("npm not found in PATH. Source your nvm/node setup or set PATH.") - sys.exit(1) - argv = [npm, "start"] + return [str(tsx), "src/entry.tsx"], tui_dir + + npm = _node_bin("npm") + return [npm, "start"], tui_dir + +def _launch_tui(resume_session_id: Optional[str] = None): + """Replace current process with the Ink TUI.""" + tui_dir = PROJECT_ROOT / "ui-tui" env = os.environ.copy() + env["HERMES_ROOT"] = os.environ.get("HERMES_ROOT", os.getcwd()) if resume_session_id: env["HERMES_TUI_RESUME"] = resume_session_id + argv, cwd = _make_tui_argv(tui_dir) try: - code = subprocess.call(argv, cwd=str(tui_dir), env=env) + code = subprocess.call(argv, cwd=str(cwd), env=env) except KeyboardInterrupt: code = 130 diff --git a/pyproject.toml b/pyproject.toml index de0e61060c..fbe7910496 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -107,7 +107,7 @@ hermes-acp = "acp_adapter.entry:main" py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "rl_cli", "utils"] [tool.setuptools.packages.find] -include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"] +include = ["agent", "tools", "tools.*", "hermes_cli", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*"] [tool.pytest.ini_options] testpaths = ["tests"] From 405c1b4e842f2c1635dfef5717880d9cf3f886ba Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:29 -0400 Subject: [PATCH 043/157] nix: add TUI derivation with buildNpmPackage - fetchNpmDeps for reproducibilty - compile ts to js - passthru.devShellHook for dev shell stamp-checked auto dep install --- nix/tui.nix | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 nix/tui.nix diff --git a/nix/tui.nix b/nix/tui.nix new file mode 100644 index 0000000000..0e88ea08c0 --- /dev/null +++ b/nix/tui.nix @@ -0,0 +1,47 @@ +# nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled +{ pkgs, ... }: +let + src = ../ui-tui; + npmDeps = pkgs.fetchNpmDeps { + inherit src; + hash = "sha256-iz6TrWec4MpfDLZR48V6XHoKnZkEn9x2t97YOqWZt5k="; + }; + + packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json")); + version = packageJson.version; + + npmLockHash = builtins.hashString "sha256" (builtins.readFile ../ui-tui/package-lock.json); +in +pkgs.buildNpmPackage { + pname = "hermes-tui"; + inherit src npmDeps version; + + doCheck = false; + + installPhase = '' + runHook preInstall + + mkdir -p $out/lib/hermes-tui + + cp -r dist $out/lib/hermes-tui/dist + + # runtime node_modules + cp -r node_modules $out/lib/hermes-tui/node_modules + + # package.json needed for "type": "module" resolution + cp package.json $out/lib/hermes-tui/ + + runHook postInstall + ''; + + passthru.devShellHook = '' + STAMP=".nix-stamps/hermes-tui" + STAMP_VALUE="${npmLockHash}" + if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then + echo "hermes-tui: installing npm dependencies..." + cd ui-tui && npm install --silent --no-fund --no-audit 2>/dev/null && cd .. + mkdir -p .nix-stamps + echo "$STAMP_VALUE" > "$STAMP" + fi + ''; +} From 31b2c12f0f541eceaca927e32865140dacfd19d1 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:29 -0400 Subject: [PATCH 044/157] nix: bundle TUI in main package with passthru hooks - build tui.nix, copy to $out/ui-tui/ (same layout as dev) - set HERMES_TUI_DIR, HERMES_PYTHON in wrapper - add passthru.devShellHook with stamp-checked venv setup - expose tui as separate package output --- nix/packages.nix | 109 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 80 insertions(+), 29 deletions(-) diff --git a/nix/packages.nix b/nix/packages.nix index eb50d4a17b..8795e35c88 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -1,54 +1,105 @@ # nix/packages.nix — Hermes Agent package built with uv2nix -{ inputs, ... }: { - perSystem = { pkgs, system, ... }: +{ inputs, ... }: +{ + perSystem = + { pkgs, ... }: let hermesVenv = pkgs.callPackage ./python.nix { inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; }; + hermesTui = pkgs.callPackage ./tui.nix { }; + # Import bundled skills, excluding runtime caches bundledSkills = pkgs.lib.cleanSourceWith { src = ../skills; - filter = path: _type: - !(pkgs.lib.hasInfix "/index-cache/" path); + filter = path: _type: !(pkgs.lib.hasInfix "/index-cache/" path); }; runtimeDeps = with pkgs; [ - nodejs_20 ripgrep git openssh ffmpeg tirith + nodejs_22 + ripgrep + git + openssh + ffmpeg + tirith ]; runtimePath = pkgs.lib.makeBinPath runtimeDeps; - in { - packages.default = pkgs.stdenv.mkDerivation { - pname = "hermes-agent"; - version = (builtins.fromTOML (builtins.readFile ../pyproject.toml)).project.version; - dontUnpack = true; - dontBuild = true; - nativeBuildInputs = [ pkgs.makeWrapper ]; + # Lockfile hashes for dev shell stamps + pyprojectHash = builtins.hashString "sha256" (builtins.readFile ../pyproject.toml); + uvLockHash = + if builtins.pathExists ../uv.lock then + builtins.hashString "sha256" (builtins.readFile ../uv.lock) + else + "none"; + in + { + packages = { + default = pkgs.stdenv.mkDerivation { + pname = "hermes-agent"; + version = (fromTOML (builtins.readFile ../pyproject.toml)).project.version; - installPhase = '' - runHook preInstall + dontUnpack = true; + dontBuild = true; + nativeBuildInputs = [ pkgs.makeWrapper ]; - mkdir -p $out/share/hermes-agent $out/bin - cp -r ${bundledSkills} $out/share/hermes-agent/skills + installPhase = '' + runHook preInstall - ${pkgs.lib.concatMapStringsSep "\n" (name: '' - makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \ - --suffix PATH : "${runtimePath}" \ - --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills - '') [ "hermes" "hermes-agent" "hermes-acp" ]} + mkdir -p $out/share/hermes-agent $out/bin + cp -r ${bundledSkills} $out/share/hermes-agent/skills - runHook postInstall - ''; + # copy pre-built TUI (same layout as dev: ui-tui/dist/ + node_modules/) + mkdir -p $out/ui-tui + cp -r ${hermesTui}/lib/hermes-tui/* $out/ui-tui/ - meta = with pkgs.lib; { - description = "AI agent with advanced tool-calling capabilities"; - homepage = "https://github.com/NousResearch/hermes-agent"; - mainProgram = "hermes"; - license = licenses.mit; - platforms = platforms.unix; + ${pkgs.lib.concatMapStringsSep "\n" + (name: '' + makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \ + --suffix PATH : "${runtimePath}" \ + --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \ + --set HERMES_TUI_DIR $out/ui-tui \ + --set HERMES_PYTHON ${hermesVenv}/bin/python3 + '') + [ + "hermes" + "hermes-agent" + "hermes-acp" + ] + } + + runHook postInstall + ''; + + passthru.devShellHook = '' + STAMP=".nix-stamps/hermes-agent" + STAMP_VALUE="${pyprojectHash}:${uvLockHash}" + if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then + echo "hermes-agent: installing Python dependencies..." + uv venv .venv --python ${pkgs.python311}/bin/python3 2>/dev/null || true + source .venv/bin/activate + uv pip install -e ".[all]" + [ -d mini-swe-agent ] && uv pip install -e ./mini-swe-agent 2>/dev/null || true + [ -d tinker-atropos ] && uv pip install -e ./tinker-atropos 2>/dev/null || true + mkdir -p .nix-stamps + echo "$STAMP_VALUE" > "$STAMP" + else + source .venv/bin/activate + fi + ''; + + meta = with pkgs.lib; { + description = "AI agent with advanced tool-calling capabilities"; + homepage = "https://github.com/NousResearch/hermes-agent"; + mainProgram = "hermes"; + license = licenses.mit; + platforms = platforms.unix; + }; }; + + tui = hermesTui; }; }; } From 21afb3fa3caa8748fc70bb44fe1ecdcbb5e42090 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:29 -0400 Subject: [PATCH 045/157] nix: delegate devShell setup to package passthru hooks - use inputsFrom to inherit build inputs from packages - concat passthru.devShellHook from each package --- nix/devShell.nix | 53 ++++++++++++++---------------------------------- 1 file changed, 15 insertions(+), 38 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index 7f8b5a1b03..db39c9d955 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -1,49 +1,26 @@ -# nix/devShell.nix — Fast dev shell with stamp-file optimization +# nix/devShell.nix — Dev shell that delegates setup to each package +# +# Each package in inputsFrom exposes passthru.devShellHook — a bash snippet +# with stamp-checked setup logic. This file collects and runs them all. { inputs, ... }: { - perSystem = { pkgs, ... }: + perSystem = { pkgs, system, ... }: let - python = pkgs.python311; + hermes-agent = inputs.self.packages.${system}.default; + hermes-tui = inputs.self.packages.${system}.tui; + packages = [ hermes-agent hermes-tui ]; in { devShells.default = pkgs.mkShell { + inputsFrom = packages; packages = with pkgs; [ - python uv nodejs_20 ripgrep git openssh ffmpeg + python311 uv nodejs_22 ripgrep git openssh ffmpeg ]; - shellHook = '' + shellHook = let + hooks = map (p: p.passthru.devShellHook or "") packages; + combined = pkgs.lib.concatStringsSep "\n" (builtins.filter (h: h != "") hooks); + in '' echo "Hermes Agent dev shell" - - # Composite stamp: changes when nix python or uv change - STAMP_VALUE="${python}:${pkgs.uv}" - STAMP_FILE=".venv/.nix-stamp" - - # Create venv if missing - if [ ! -d .venv ]; then - echo "Creating Python 3.11 venv..." - uv venv .venv --python ${python}/bin/python3 - fi - - source .venv/bin/activate - - # Only install if stamp is stale or missing - if [ ! -f "$STAMP_FILE" ] || [ "$(cat "$STAMP_FILE")" != "$STAMP_VALUE" ]; then - echo "Installing Python dependencies..." - uv pip install -e ".[all]" - if [ -d mini-swe-agent ]; then - uv pip install -e ./mini-swe-agent 2>/dev/null || true - fi - if [ -d tinker-atropos ]; then - uv pip install -e ./tinker-atropos 2>/dev/null || true - fi - - # Install npm deps - if [ -f package.json ] && [ ! -d node_modules ]; then - echo "Installing npm dependencies..." - npm install - fi - - echo "$STAMP_VALUE" > "$STAMP_FILE" - fi - + ${combined} echo "Ready. Run 'hermes' to start." ''; }; From df5874c119a4b39f45c65fb3b43a337abffddbfd Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:29 -0400 Subject: [PATCH 046/157] nix: add bundled TUI build-time verification check --- nix/checks.nix | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/nix/checks.nix b/nix/checks.nix index 6dd5115c93..55068a94f1 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -103,6 +103,28 @@ json.dump(sorted(leaf_paths(DEFAULT_CONFIG)), sys.stdout, indent=2) echo "ok" > $out/result ''; + # Verify bundled TUI is present and compiled + bundled-tui = pkgs.runCommand "hermes-bundled-tui" { } '' + set -e + echo "=== Checking bundled TUI ===" + test -d ${hermes-agent}/ui-tui || (echo "FAIL: ui-tui directory missing"; exit 1) + echo "PASS: ui-tui directory exists" + + test -f ${hermes-agent}/ui-tui/dist/entry.js || (echo "FAIL: compiled entry.js missing"; exit 1) + echo "PASS: compiled entry.js present" + + test -d ${hermes-agent}/ui-tui/node_modules || (echo "FAIL: node_modules missing"; exit 1) + echo "PASS: node_modules present" + + grep -q "HERMES_TUI_DIR" ${hermes-agent}/bin/hermes || \ + (echo "FAIL: HERMES_TUI_DIR not in wrapper"; exit 1) + echo "PASS: HERMES_TUI_DIR set in wrapper" + + echo "=== All bundled TUI checks passed ===" + mkdir -p $out + echo "ok" > $out/result + ''; + # Verify HERMES_MANAGED guard works on all mutation commands managed-guard = pkgs.runCommand "hermes-managed-guard" { } '' set -e From 74241328f0bac5f9f8e6c7a21193616ebb776397 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 9 Apr 2026 15:30:42 -0400 Subject: [PATCH 047/157] direnv: watch lockfiles/nix files; gitignore .nix-stamps --- .envrc | 4 ++++ .gitignore | 1 + 2 files changed, 5 insertions(+) diff --git a/.envrc b/.envrc index 3550a30f2d..a98c03b2c6 100644 --- a/.envrc +++ b/.envrc @@ -1 +1,5 @@ +watch_file pyproject.toml uv.lock +watch_file ui-tui/package-lock.json ui-tui/package.json +watch_file nix/ + use flake diff --git a/.gitignore b/.gitignore index baa31a543c..b3cc8bfffa 100644 --- a/.gitignore +++ b/.gitignore @@ -57,4 +57,5 @@ mini-swe-agent/ # Nix .direnv/ +.nix-stamps/ result From b7d4ea15507a6fb72df81fb78f402c2362dea435 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 15:13:43 -0500 Subject: [PATCH 048/157] feat: better hyperlink formatting --- ui-tui/src/components/markdown.tsx | 21 +++++++++++++++++---- ui-tui/src/lib/text.ts | 2 +- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 9a75952f54..5882ab8c7e 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -3,6 +3,10 @@ import type { ReactNode } from 'react' import type { Theme } from '../theme.js' +/** OSC 8 hyperlink — wrap-ansi / Ink keep the link active across soft line wraps. */ +const osc8 = (url: string) => '\x1b]8;;' + url + '\x1b\\' +const OSC8_END = '\x1b]8;;\x1b\\' + function MdInline({ t, text }: { t: Theme; text: string }) { const parts: ReactNode[] = [] const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g @@ -18,8 +22,12 @@ function MdInline({ t, text }: { t: Theme; text: string }) { if (m[2] && m[3]) { parts.push( - - {m[2]} + + {osc8(m[3])} + + {m[2]} + + {OSC8_END} ) } else if (m[4]) { @@ -41,9 +49,14 @@ function MdInline({ t, text }: { t: Theme; text: string }) { ) } else if (m[7]) { + const u = m[7] parts.push( - - {m[7]} + + {osc8(u)} + + {u} + + {OSC8_END} ) } diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 264d741fde..44d6a31522 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -5,7 +5,7 @@ const ANSI_RE = /\x1b\[[0-9;]*m/g export const stripAnsi = (s: string) => s.replace(ANSI_RE, '') -export const hasAnsi = (s: string) => s.includes('\x1b[') +export const hasAnsi = (s: string) => s.includes('\x1b[') || s.includes('\x1b]') const renderEstimateLine = (line: string) => { const trimmed = line.trim() From c5511bbc5abb19ec380e72ec7ffa2d9ef92ce2f3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 16:27:06 -0500 Subject: [PATCH 049/157] fix: leading ./ thingy --- tui_gateway/server.py | 2 ++ ui-tui/src/app.tsx | 2 +- ui-tui/src/constants.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 059bbc394e..f0b80ad502 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1111,6 +1111,8 @@ def _(rid, params: dict) -> dict: text = f"@{kind}:{rel}{suffix}" elif word.startswith("~"): text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix + elif word.startswith("./"): + text = "./" + rel + suffix else: text = rel + suffix diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 2efaca53ea..96bbfc720d 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -907,7 +907,7 @@ export function App({ gw }: { gw: GatewayClient }) { } else if (input || inputBuf.length) { clearIn() } else { - die() + return die() } return diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index c244e6b584..2d755c3420 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -20,7 +20,7 @@ export const FACES = [ ] export const HOTKEYS: [string, string][] = [ - ['Ctrl+C', 'interrupt / clear / exit'], + ['Ctrl+C', 'interrupt / clear draft / exit'], ['Ctrl+D', 'exit'], ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'new session (clear)'], From 99fd3b518d94f5e878834da95230e11f396eb0b9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 17:19:36 -0500 Subject: [PATCH 050/157] feat: add /copy and /agents --- cli.py | 134 +++++++++++++++--- gateway/platforms/base.py | 2 +- gateway/run.py | 97 +++++++++++++ hermes_cli/commands.py | 4 + run_agent.py | 1 + tests/cli/test_cli_copy_command.py | 71 ++++++++++ .../test_command_bypass_active_session.py | 24 ++++ tests/gateway/test_status_command.py | 70 +++++++++ tests/hermes_cli/test_commands.py | 3 + tools/process_registry.py | 11 ++ ui-tui/src/app.tsx | 21 ++- 11 files changed, 415 insertions(+), 23 deletions(-) create mode 100644 tests/cli/test_cli_copy_command.py diff --git a/cli.py b/cli.py index fa32ae9119..8b5bfea2d0 100644 --- a/cli.py +++ b/cli.py @@ -18,6 +18,8 @@ import os import shutil import sys import json +import re +import base64 import atexit import tempfile import time @@ -78,6 +80,42 @@ _project_env = Path(__file__).parent / '.env' load_hermes_dotenv(hermes_home=_hermes_home, project_env=_project_env) +_REASONING_TAGS = ( + "REASONING_SCRATCHPAD", + "think", + "reasoning", + "THINKING", + "thinking", +) + + +def _strip_reasoning_tags(text: str) -> str: + cleaned = text + for tag in _REASONING_TAGS: + cleaned = re.sub(rf"<{tag}>.*?\s*", "", cleaned, flags=re.DOTALL) + cleaned = re.sub(rf"<{tag}>.*$", "", cleaned, flags=re.DOTALL) + return cleaned.strip() + + +def _assistant_content_as_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [ + str(part.get("text", "")) + for part in content + if isinstance(part, dict) and part.get("type") == "text" + ] + return "\n".join(p for p in parts if p) + return str(content) + + +def _assistant_copy_text(content: Any) -> str: + return _strip_reasoning_tags(_assistant_content_as_text(content)) + + # ============================================================================= # Configuration Loading # ============================================================================= @@ -2659,21 +2697,6 @@ class HermesCLI: MAX_ASST_LEN = 200 # truncate assistant text MAX_ASST_LINES = 3 # max lines of assistant text - def _strip_reasoning(text: str) -> str: - """Remove ... blocks - from displayed text (reasoning model internal thoughts).""" - import re - cleaned = re.sub( - r".*?\s*", - "", text, flags=re.DOTALL, - ) - # Also strip unclosed reasoning tags at the end - cleaned = re.sub( - r".*$", - "", cleaned, flags=re.DOTALL, - ) - return cleaned.strip() - # Collect displayable entries (skip system, tool-result messages) entries = [] # list of (role, display_text) for msg in self.conversation_history: @@ -2703,7 +2726,7 @@ class HermesCLI: elif role == "assistant": text = "" if content is None else str(content) - text = _strip_reasoning(text) + text = _strip_reasoning_tags(text) parts = [] if text: lines = text.splitlines() @@ -2935,6 +2958,26 @@ class HermesCLI: killed = process_registry.kill_all() print(f" ✅ Stopped {killed} process(es).") + def _handle_agents_command(self): + """Handle /agents — show background processes and agent status.""" + from tools.process_registry import format_uptime_short, process_registry + + processes = process_registry.list_sessions() + running = [p for p in processes if p.get("status") == "running"] + finished = [p for p in processes if p.get("status") != "running"] + + _cprint(f" Running processes: {len(running)}") + for p in running: + cmd = p.get("command", "")[:80] + up = format_uptime_short(p.get("uptime_seconds", 0)) + _cprint(f" {p.get('session_id', '?')} · {up} · {cmd}") + + if finished: + _cprint(f" Recently finished: {len(finished)}") + + agent_running = getattr(self, "_agent_running", False) + _cprint(f" Agent: {'running' if agent_running else 'idle'}") + def _handle_paste_command(self): """Handle /paste — explicitly check clipboard for an image. @@ -2952,6 +2995,61 @@ class HermesCLI: else: _cprint(f" {_DIM}(._.) No image found in clipboard{_RST}") + def _write_osc52_clipboard(self, text: str) -> None: + """Copy *text* to terminal clipboard via OSC 52.""" + payload = base64.b64encode(text.encode("utf-8")).decode("ascii") + seq = f"\x1b]52;c;{payload}\x07" + out = getattr(self, "_app", None) + output = getattr(out, "output", None) if out else None + if output and hasattr(output, "write_raw"): + output.write_raw(seq) + output.flush() + return + if output and hasattr(output, "write"): + output.write(seq) + output.flush() + return + sys.stdout.write(seq) + sys.stdout.flush() + + def _handle_copy_command(self, cmd_original: str) -> None: + """Handle /copy [number] — copy assistant output to clipboard.""" + parts = cmd_original.split(maxsplit=1) + arg = parts[1].strip() if len(parts) > 1 else "" + + assistant = [m for m in self.conversation_history if m.get("role") == "assistant"] + if not assistant: + _cprint(" Nothing to copy yet.") + return + + if arg: + try: + idx = int(arg) - 1 + except ValueError: + _cprint(" Usage: /copy [number]") + return + if idx < 0 or idx >= len(assistant): + _cprint(f" Invalid response number. Use 1-{len(assistant)}.") + return + else: + idx = len(assistant) - 1 + while idx >= 0 and not _assistant_copy_text(assistant[idx].get("content")): + idx -= 1 + if idx < 0: + _cprint(" Nothing to copy in assistant responses yet.") + return + + text = _assistant_copy_text(assistant[idx].get("content")) + if not text: + _cprint(" Nothing to copy in that assistant response.") + return + + try: + self._write_osc52_clipboard(text) + _cprint(f" Copied assistant response #{idx + 1} to clipboard") + except Exception as e: + _cprint(f" Clipboard copy failed: {e}") + def _preprocess_images_with_vision(self, text: str, images: list) -> str: """Analyze attached images via the vision tool and return enriched text. @@ -4598,6 +4696,8 @@ class HermesCLI: self._show_usage() elif canonical == "insights": self._show_insights(cmd_original) + elif canonical == "copy": + self._handle_copy_command(cmd_original) elif canonical == "paste": self._handle_paste_command() elif canonical == "reload-mcp": @@ -4630,6 +4730,8 @@ class HermesCLI: self._handle_rollback_command(cmd_original) elif canonical == "stop": self._handle_stop_command() + elif canonical == "agents": + self._handle_agents_command() elif canonical == "background": self._handle_background_command(cmd_original) elif canonical == "btw": diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index bd07459ac8..aa40ece6d8 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1170,7 +1170,7 @@ class BasePlatformAdapter(ABC): # session lifecycle and its cleanup races with the running task # (see PR #4926). cmd = event.get_command() - if cmd in ("approve", "deny", "status", "stop", "new", "reset"): + if cmd in ("approve", "deny", "status", "agents", "tasks", "stop", "new", "reset"): logger.debug( "[%s] Command '/%s' bypassing active-session guard for %s", self.name, cmd, session_key, diff --git a/gateway/run.py b/gateway/run.py index 339954f5be..91e4a7d567 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1997,6 +1997,10 @@ class GatewayRunner: return await self._handle_approve_command(event) return await self._handle_deny_command(event) + # /agents (/tasks alias) should be query-only and never interrupt. + if _cmd_def_inner and _cmd_def_inner.name == "agents": + return await self._handle_agents_command(event) + if event.message_type == MessageType.PHOTO: logger.debug("PRIORITY photo follow-up for session %s — queueing without interrupt", _quick_key[:20]) adapter = self.adapters.get(source.platform) @@ -2071,6 +2075,9 @@ class GatewayRunner: if canonical == "status": return await self._handle_status_command(event) + + if canonical == "agents": + return await self._handle_agents_command(event) if canonical == "stop": return await self._handle_stop_command(event) @@ -3412,6 +3419,96 @@ class GatewayRunner: ]) return "\n".join(lines) + + async def _handle_agents_command(self, event: MessageEvent) -> str: + """Handle /agents command - list active agents and running tasks.""" + from tools.process_registry import format_uptime_short, process_registry + + now = time.time() + current_session_key = self._session_key_for_source(event.source) + + running_agents: dict = getattr(self, "_running_agents", {}) or {} + running_started: dict = getattr(self, "_running_agents_ts", {}) or {} + + agent_rows: list[dict] = [] + for session_key, agent in running_agents.items(): + started = float(running_started.get(session_key, now)) + elapsed = max(0, int(now - started)) + is_pending = agent is _AGENT_PENDING_SENTINEL + agent_rows.append( + { + "session_key": session_key, + "elapsed": elapsed, + "state": "starting" if is_pending else "running", + "session_id": "" if is_pending else str(getattr(agent, "session_id", "") or ""), + "model": "" if is_pending else str(getattr(agent, "model", "") or ""), + } + ) + + agent_rows.sort(key=lambda row: row["elapsed"], reverse=True) + + running_processes: list[dict] = [] + try: + running_processes = [ + p for p in process_registry.list_sessions() + if p.get("status") == "running" + ] + except Exception: + running_processes = [] + + background_tasks = [ + t for t in (getattr(self, "_background_tasks", set()) or set()) + if hasattr(t, "done") and not t.done() + ] + + lines = [ + "🤖 **Active Agents & Tasks**", + "", + f"**Active agents:** {len(agent_rows)}", + ] + + if agent_rows: + for idx, row in enumerate(agent_rows[:12], 1): + current = " · this chat" if row["session_key"] == current_session_key else "" + sid = f" · `{row['session_id']}`" if row["session_id"] else "" + model = f" · `{row['model']}`" if row["model"] else "" + lines.append( + f"{idx}. `{row['session_key']}` · {row['state']} · " + f"{format_uptime_short(row['elapsed'])}{sid}{model}{current}" + ) + if len(agent_rows) > 12: + lines.append(f"... and {len(agent_rows) - 12} more") + + lines.extend( + [ + "", + f"**Running background processes:** {len(running_processes)}", + ] + ) + if running_processes: + for proc in running_processes[:12]: + cmd = " ".join(str(proc.get("command", "")).split()) + if len(cmd) > 90: + cmd = cmd[:87] + "..." + lines.append( + f"- `{proc.get('session_id', '?')}` · " + f"{format_uptime_short(int(proc.get('uptime_seconds', 0)))} · `{cmd}`" + ) + if len(running_processes) > 12: + lines.append(f"... and {len(running_processes) - 12} more") + + lines.extend( + [ + "", + f"**Gateway async jobs:** {len(background_tasks)}", + ] + ) + + if not agent_rows and not running_processes and not background_tasks: + lines.append("") + lines.append("No active agents or running tasks.") + + return "\n".join(lines) async def _handle_stop_command(self, event: MessageEvent) -> str: """Handle /stop command - interrupt a running agent. diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 70d9cb8aa3..917e8b1e02 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -71,6 +71,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("bg",), args_hint=""), CommandDef("btw", "Ephemeral side question using session context (no tools, not persisted)", "Session", args_hint=""), + CommandDef("agents", "Show active agents and running tasks", "Session", + aliases=("tasks",)), CommandDef("queue", "Queue a prompt for the next turn (doesn't interrupt)", "Session", aliases=("q",), args_hint=""), CommandDef("status", "Show session info", "Session", @@ -134,6 +136,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[days]"), CommandDef("platforms", "Show gateway/messaging platform status", "Info", cli_only=True, aliases=("gateway",)), + CommandDef("copy", "Copy the last assistant response to clipboard", "Info", + cli_only=True, args_hint="[number]"), CommandDef("paste", "Check clipboard for an image and attach it", "Info", cli_only=True), CommandDef("update", "Update Hermes Agent to the latest version", "Info", diff --git a/run_agent.py b/run_agent.py index fc04706838..d7234f2964 100644 --- a/run_agent.py +++ b/run_agent.py @@ -4739,6 +4739,7 @@ class AIAgent: ) except Exception: pass + self._emit_status("🔄 Reconnected — resuming…") continue self._emit_status( "❌ Connection to provider failed after " diff --git a/tests/cli/test_cli_copy_command.py b/tests/cli/test_cli_copy_command.py new file mode 100644 index 0000000000..6cd010df37 --- /dev/null +++ b/tests/cli/test_cli_copy_command.py @@ -0,0 +1,71 @@ +"""Tests for CLI /copy command.""" + +from unittest.mock import MagicMock, patch + +from cli import HermesCLI + + +def _make_cli() -> HermesCLI: + cli_obj = HermesCLI.__new__(HermesCLI) + cli_obj.config = {} + cli_obj.console = MagicMock() + cli_obj.agent = None + cli_obj.conversation_history = [] + cli_obj.session_id = "sess-copy-test" + cli_obj._pending_input = MagicMock() + cli_obj._app = None + return cli_obj + + +def test_copy_copies_latest_assistant_message(): + cli_obj = _make_cli() + cli_obj.conversation_history = [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "first"}, + {"role": "assistant", "content": "latest"}, + ] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy: + result = cli_obj.process_command("/copy") + + assert result is True + mock_copy.assert_called_once_with("latest") + + +def test_copy_with_index_uses_requested_assistant_message(): + cli_obj = _make_cli() + cli_obj.conversation_history = [ + {"role": "assistant", "content": "one"}, + {"role": "assistant", "content": "two"}, + ] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy: + cli_obj.process_command("/copy 1") + + mock_copy.assert_called_once_with("one") + + +def test_copy_strips_reasoning_blocks_before_copy(): + cli_obj = _make_cli() + cli_obj.conversation_history = [ + { + "role": "assistant", + "content": "internal\nVisible answer", + } + ] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy: + cli_obj.process_command("/copy") + + mock_copy.assert_called_once_with("Visible answer") + + +def test_copy_invalid_index_does_not_copy(): + cli_obj = _make_cli() + cli_obj.conversation_history = [{"role": "assistant", "content": "only"}] + + with patch.object(cli_obj, "_write_osc52_clipboard") as mock_copy, patch("cli._cprint") as mock_print: + cli_obj.process_command("/copy 99") + + mock_copy.assert_not_called() + assert any("Invalid response number" in str(call) for call in mock_print.call_args_list) diff --git a/tests/gateway/test_command_bypass_active_session.py b/tests/gateway/test_command_bypass_active_session.py index e90dee69c1..e36a1473fe 100644 --- a/tests/gateway/test_command_bypass_active_session.py +++ b/tests/gateway/test_command_bypass_active_session.py @@ -160,6 +160,30 @@ class TestCommandBypassActiveSession: assert sk not in adapter._pending_messages assert any("handled:status" in r for r in adapter.sent_responses) + @pytest.mark.asyncio + async def test_agents_bypasses_guard(self): + """/agents must bypass so active-task queries don't interrupt runs.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/agents")) + + assert sk not in adapter._pending_messages + assert any("handled:agents" in r for r in adapter.sent_responses) + + @pytest.mark.asyncio + async def test_tasks_alias_bypasses_guard(self): + """/tasks alias must bypass active-session guard too.""" + adapter = _make_adapter() + sk = _session_key() + adapter._active_sessions[sk] = asyncio.Event() + + await adapter.handle_message(_make_event("/tasks")) + + assert sk not in adapter._pending_messages + assert any("handled:tasks" in r for r in adapter.sent_responses) + # --------------------------------------------------------------------------- # Tests: non-bypass messages still get queued diff --git a/tests/gateway/test_status_command.py b/tests/gateway/test_status_command.py index 0dbd5980b0..1cd58f3c6a 100644 --- a/tests/gateway/test_status_command.py +++ b/tests/gateway/test_status_command.py @@ -1,6 +1,7 @@ """Tests for gateway /status behavior and token persistence.""" from datetime import datetime +import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock @@ -111,6 +112,75 @@ async def test_status_command_includes_session_title_when_present(): assert "**Title:** My titled session" in result +@pytest.mark.asyncio +async def test_agents_command_reports_active_agents_and_processes(monkeypatch): + session_key = build_session_key(_make_source()) + session_entry = SessionEntry( + session_key=session_key, + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + runner = _make_runner(session_entry) + running_agent = SimpleNamespace( + session_id="sess-running", + model="openrouter/test-model", + interrupt=MagicMock(), + get_activity_summary=lambda: {"seconds_since_activity": 0}, + ) + runner._running_agents[session_key] = running_agent + runner._running_agents_ts = {session_key: time.time() - 8} + runner._background_tasks = set() + + class _FakeRegistry: + def list_sessions(self): + return [ + { + "session_id": "proc-1", + "status": "running", + "uptime_seconds": 17, + "command": "sleep 30", + } + ] + + monkeypatch.setattr("tools.process_registry.process_registry", _FakeRegistry()) + + result = await runner._handle_message(_make_event("/agents")) + + assert "**Active agents:** 1" in result + assert "**Running background processes:** 1" in result + assert "proc-1" in result + running_agent.interrupt.assert_not_called() + + +@pytest.mark.asyncio +async def test_tasks_alias_routes_to_agents_command(monkeypatch): + session_entry = SessionEntry( + session_key=build_session_key(_make_source()), + session_id="sess-1", + created_at=datetime.now(), + updated_at=datetime.now(), + platform=Platform.TELEGRAM, + chat_type="dm", + total_tokens=0, + ) + runner = _make_runner(session_entry) + runner._background_tasks = set() + + class _FakeRegistry: + def list_sessions(self): + return [] + + monkeypatch.setattr("tools.process_registry.process_registry", _FakeRegistry()) + + result = await runner._handle_message(_make_event("/tasks")) + + assert "Active Agents & Tasks" in result + + @pytest.mark.asyncio async def test_handle_message_persists_agent_token_counts(monkeypatch): import gateway.run as gateway_run diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 81c262a840..3b57bf07aa 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -82,6 +82,8 @@ class TestResolveCommand: def test_canonical_name_resolves(self): assert resolve_command("help").name == "help" assert resolve_command("background").name == "background" + assert resolve_command("copy").name == "copy" + assert resolve_command("agents").name == "agents" def test_alias_resolves_to_canonical(self): assert resolve_command("bg").name == "background" @@ -91,6 +93,7 @@ class TestResolveCommand: assert resolve_command("gateway").name == "platforms" assert resolve_command("set-home").name == "sethome" assert resolve_command("reload_mcp").name == "reload-mcp" + assert resolve_command("tasks").name == "agents" def test_leading_slash_stripped(self): assert resolve_command("/help").name == "help" diff --git a/tools/process_registry.py b/tools/process_registry.py index b935f49c33..2adad9e470 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -59,6 +59,17 @@ FINISHED_TTL_SECONDS = 1800 # Keep finished processes for 30 minutes MAX_PROCESSES = 64 # Max concurrent tracked processes (LRU pruning) +def format_uptime_short(seconds: int) -> str: + s = max(0, int(seconds)) + if s < 60: + return f"{s}s" + mins, secs = divmod(s, 60) + if mins < 60: + return f"{mins}m {secs}s" + hours, mins = divmod(mins, 60) + return f"{hours}h {mins}m" + + @dataclass class ProcessSession: """A tracked background process with output buffering.""" diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 96bbfc720d..726ea9f9c2 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -297,8 +297,10 @@ export function App({ gw }: { gw: GatewayClient }) { const colsRef = useRef(cols) const turnToolsRef = useRef([]) const statusTimerRef = useRef | null>(null) + const busyRef = useRef(busy) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) colsRef.current = cols + busyRef.current = busy reasoningRef.current = reasoning // ── Hooks ──────────────────────────────────────────────────────── @@ -1011,12 +1013,19 @@ export function App({ gw }: { gw: GatewayClient }) { if (p?.text) { setStatus(p.text) - if (p.kind && p.kind !== 'status' && lastStatusNoteRef.current !== p.text) { - lastStatusNoteRef.current = p.text - pushActivity( - p.text, - p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' - ) + if (p.kind && p.kind !== 'status') { + if (lastStatusNoteRef.current !== p.text) { + lastStatusNoteRef.current = p.text + pushActivity( + p.text, + p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' + ) + } + if (statusTimerRef.current) clearTimeout(statusTimerRef.current) + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus(busyRef.current ? 'running…' : 'ready') + }, 4000) } } From 6e24b9947e6f3cf2b11a8b7e2a849a9b23442dae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 17:40:30 -0500 Subject: [PATCH 051/157] feat(ui-tui): render tool calls inline in message flow instead of activity lane --- ui-tui/src/app.tsx | 32 ++++++------ ui-tui/src/components/messageLine.tsx | 25 ++++----- ui-tui/src/components/thinking.tsx | 74 +++++++++++++++------------ 3 files changed, 66 insertions(+), 65 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 726ea9f9c2..0a0ec2f030 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -6,7 +6,6 @@ import { join } from 'node:path' import { Box, Text, useApp, useInput, useStdout } from 'ink' import { useCallback, useEffect, useRef, useState } from 'react' -import { ActivityLane } from './components/activityLane.js' import { Banner, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' @@ -14,7 +13,7 @@ import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { type PasteEvent, TextInput } from './components/textInput.js' -import { Thinking } from './components/thinking.js' +import { Thinking, ToolTrail } from './components/thinking.js' import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import { useCompletion } from './hooks/useCompletion.js' @@ -278,6 +277,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [pastes, setPastes] = useState([]) const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null) const [streaming, setStreaming] = useState('') + const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) @@ -374,6 +374,7 @@ export function App({ gw }: { gw: GatewayClient }) { const idle = () => { setThinking(false) setTools([]) + setTurnTrail([]) setBusy(false) setClarify(null) setApproval(null) @@ -1005,6 +1006,7 @@ export function App({ gw }: { gw: GatewayClient }) { setBusy(true) setReasoning('') setActivity([]) + setTurnTrail([]) turnToolsRef.current = [] break @@ -1021,7 +1023,11 @@ export function App({ gw }: { gw: GatewayClient }) { p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' ) } - if (statusTimerRef.current) clearTimeout(statusTimerRef.current) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + statusTimerRef.current = setTimeout(() => { statusTimerRef.current = null setStatus(busyRef.current ? 'running…' : 'ready') @@ -1067,7 +1073,6 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.complete': { const mark = p.error ? '✗' : '✓' - const tone = p.error ? 'error' : 'info' toolCompleteRibbonRef.current = null setTools(prev => { @@ -1077,16 +1082,13 @@ export function App({ gw }: { gw: GatewayClient }) { const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` toolCompleteRibbonRef.current = { label, line } - turnToolsRef.current = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) + const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) + turnToolsRef.current = next + setTurnTrail(next) return prev.filter(t => t.id !== p.tool_id) }) - if (toolCompleteRibbonRef.current) { - const { line, label } = toolCompleteRibbonRef.current - pushActivity(line, tone, label) - } - break } @@ -1787,16 +1789,14 @@ export function App({ gw }: { gw: GatewayClient }) { ))} + + + {thinking && !tools.length && !streaming && } + {streaming && ( )} - {(thinking || tools.length > 0) && (!streaming || tools.length > 0) && ( - - )} - - {busy && } - {pasteReview && ( diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 76d0d17430..91d1fe8c33 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -7,6 +7,7 @@ import type { Theme } from '../theme.js' import type { Msg } from '../types.js' import { Md } from './markdown.js' +import { ToolTrail } from './thinking.js' export const MessageLine = memo(function MessageLine({ cols, @@ -19,8 +20,6 @@ export const MessageLine = memo(function MessageLine({ msg: Msg t: Theme }) { - const { body, glyph, prefix } = ROLE[msg.role](t) - if (msg.role === 'tool') { return ( @@ -29,6 +28,8 @@ export const MessageLine = memo(function MessageLine({ ) } + const { body, glyph, prefix } = ROLE[msg.role](t) + const content = (() => { if (msg.role === 'assistant') { return hasAnsi(msg.text) ? {msg.text} : @@ -59,6 +60,12 @@ export const MessageLine = memo(function MessageLine({ )} + {msg.tools?.length ? ( + + + + ) : null} + @@ -68,20 +75,6 @@ export const MessageLine = memo(function MessageLine({ {content} - - {!!msg.tools?.length && ( - - {msg.tools.map((tool, i) => ( - - {t.brand.tool} {tool} - - ))} - - )} ) }) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index b2aff03550..be30a3dc4c 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -6,13 +6,13 @@ import { FACES, TOOL_VERBS, VERBS } from '../constants.js' import type { Theme } from '../theme.js' import type { ActiveTool } from '../types.js' -const THINK_POOL: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] -const TOOL_POOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] +const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] +const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! -function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { - const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL_POOL : THINK_POOL)]) +export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { + const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL : THINK)]) const [frame, setFrame] = useState(0) useEffect(() => { @@ -24,15 +24,38 @@ function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think return {spin.frames[frame]} } -export const Thinking = memo(function Thinking({ - reasoning, +export const ToolTrail = memo(function ToolTrail({ t, - tools + tools = [], + trail = [] }: { - reasoning: string t: Theme - tools: ActiveTool[] + tools?: ActiveTool[] + trail?: string[] }) { + if (!trail.length && !tools.length) { + return null + } + + return ( + <> + {trail.map((line, i) => ( + + {t.brand.tool} {line} + + ))} + + {tools.map(tool => ( + + {TOOL_VERBS[tool.name] ?? tool.name} + {tool.context ? `: ${tool.context}` : ''} + + ))} + + ) +}) + +export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) { const [tick, setTick] = useState(0) useEffect(() => { @@ -41,31 +64,16 @@ export const Thinking = memo(function Thinking({ return () => clearInterval(id) }, []) - const verb = VERBS[tick % VERBS.length] ?? 'thinking' - const face = FACES[tick % FACES.length] ?? '(•_•)' const tail = reasoning.slice(-160).replace(/\n/g, ' ') - const hasReasoning = !!tail - return ( - <> - {tools.map(tool => ( - - {TOOL_VERBS[tool.name] ?? tool.name} - {tool.context ? `: ${tool.context}` : ''} - - ))} - - {!tools.length && !hasReasoning && ( - - {face} {verb}… - - )} - - {tail && ( - - 💭 {tail} - - )} - + return tail ? ( + + 💭 {tail} + + ) : ( + + {FACES[tick % FACES.length] ?? '(•_•)'} {VERBS[tick % VERBS.length] ?? 'thinking'} + … + ) }) From 7e813a30e05ba98d1ce54e46ad0385628e2bc885 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 18:33:25 -0500 Subject: [PATCH 052/157] fix: sexier cots --- ui-tui/src/__tests__/constants.test.ts | 6 +- ui-tui/src/__tests__/text.test.ts | 29 +++++-- ui-tui/src/app.tsx | 51 +++++++++-- ui-tui/src/components/thinking.tsx | 116 ++++++++++++++++++++----- ui-tui/src/constants.ts | 35 ++++---- ui-tui/src/lib/text.ts | 14 +++ 6 files changed, 191 insertions(+), 60 deletions(-) diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts index c469883079..36ca9a0ad6 100644 --- a/ui-tui/src/__tests__/constants.test.ts +++ b/ui-tui/src/__tests__/constants.test.ts @@ -20,9 +20,9 @@ describe('constants', () => { }) }) - it('TOOL_VERBS maps known tools', () => { - expect(TOOL_VERBS.terminal).toContain('terminal') - expect(TOOL_VERBS.read_file).toContain('reading') + it('TOOL_VERBS maps known tools (verb-only, no emoji)', () => { + expect(TOOL_VERBS.terminal).toBe('terminal') + expect(TOOL_VERBS.read_file).toBe('reading') }) it('INTERPOLATION_RE matches {!cmd}', () => { diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 1d61b71b1f..55b6a272b3 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,21 +1,36 @@ import { describe, expect, it } from 'vitest' -import { fmtK, sameToolTrailGroup } from '../lib/text.js' +import { fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js' + +describe('isToolTrailResultLine', () => { + it('detects completion markers', () => { + expect(isToolTrailResultLine('foo ✓')).toBe(true) + expect(isToolTrailResultLine('foo ✗')).toBe(true) + expect(isToolTrailResultLine('drafting x…')).toBe(false) + }) +}) + +describe('lastCotTrailIndex', () => { + it('finds last non-result line', () => { + expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1) + expect(lastCotTrailIndex(['only result ✓'])).toBe(-1) + }) +}) describe('sameToolTrailGroup', () => { it('matches bare check lines', () => { - expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✓')).toBe(true) - expect(sameToolTrailGroup('🔍 searching', '🔍 searching ✗')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching ✓')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching ✗')).toBe(true) }) it('matches contextual lines', () => { - expect(sameToolTrailGroup('🔍 searching', '🔍 searching: * ✓')).toBe(true) - expect(sameToolTrailGroup('🔍 searching', '🔍 searching: foo ✓')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching: * ✓')).toBe(true) + expect(sameToolTrailGroup('searching', 'searching: foo ✓')).toBe(true) }) it('rejects other tools', () => { - expect(sameToolTrailGroup('🔍 searching', '📖 reading ✓')).toBe(false) - expect(sameToolTrailGroup('🔍 searching', '🔍 searching extra ✓')).toBe(false) + expect(sameToolTrailGroup('searching', 'reading ✓')).toBe(false) + expect(sameToolTrailGroup('searching', 'searching extra ✓')).toBe(false) }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 0a0ec2f030..06399ba7ad 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,7 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { compactPreview, fmtK, hasInterpolation, pick, sameToolTrailGroup } from './lib/text.js' +import { compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -363,6 +363,19 @@ export function App({ gw }: { gw: GatewayClient }) { }) }, []) + const pushTrail = useCallback((line: string) => { + setTurnTrail(prev => { + if (prev.at(-1) === line) { + return prev + } + + const next = [...prev, line].slice(-8) + turnToolsRef.current = next + + return next + }) + }, []) + const rpc = useCallback( (method: string, params: Record = {}) => gw.request(method, params).catch((e: Error) => { @@ -1067,6 +1080,13 @@ export function App({ gw }: { gw: GatewayClient }) { break + case 'tool.generating': + if (p?.name) { + pushTrail(`drafting ${p.name}…`) + } + + break + case 'tool.start': setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }]) @@ -1082,11 +1102,18 @@ export function App({ gw }: { gw: GatewayClient }) { const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` toolCompleteRibbonRef.current = { label, line } - const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line].slice(-8) - turnToolsRef.current = next - setTurnTrail(next) + const remaining = prev.filter(t => t.id !== p.tool_id) + const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line] - return prev.filter(t => t.id !== p.tool_id) + if (!remaining.length) { + next.push('analyzing tool output…') + } + + const pruned = next.slice(-8) + turnToolsRef.current = pruned + setTurnTrail(pruned) + + return remaining }) break @@ -1148,7 +1175,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.complete': { const wasInterrupted = interruptedRef.current const savedReasoning = reasoningRef.current.trim() - const savedTools = [...turnToolsRef.current] + const savedTools = turnToolsRef.current.filter(isToolTrailResultLine) const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart() idle() @@ -1204,7 +1231,7 @@ export function App({ gw }: { gw: GatewayClient }) { break } }, - [appendMessage, dequeue, newSession, pushActivity, send, sys] + [appendMessage, dequeue, newSession, pushActivity, pushTrail, send, sys] ) onEventRef.current = onEvent @@ -1789,9 +1816,15 @@ export function App({ gw }: { gw: GatewayClient }) { ))} - + - {thinking && !tools.length && !streaming && } + {busy && !tools.length && !streaming && } {streaming && ( diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index be30a3dc4c..5dbcfdab47 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -3,16 +3,29 @@ import { memo, useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { FACES, TOOL_VERBS, VERBS } from '../constants.js' +import { isToolTrailResultLine, lastCotTrailIndex } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool } from '../types.js' +import type { ActiveTool, ActivityItem } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! +const tone = (item: ActivityItem, t: Theme) => + item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim + +const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '⚠' : '·') + +const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '} + export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { - const [spin] = useState(() => spinners[pick(variant === 'tool' ? TOOL : THINK)]) + const [spin] = useState(() => { + const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] + + return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } + }) + const [frame, setFrame] = useState(0) useEffect(() => { @@ -27,30 +40,81 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: export const ToolTrail = memo(function ToolTrail({ t, tools = [], - trail = [] + trail = [], + activity = [], + animateCot = false }: { t: Theme tools?: ActiveTool[] trail?: string[] + activity?: ActivityItem[] + animateCot?: boolean }) { - if (!trail.length && !tools.length) { + if (!trail.length && !tools.length && !activity.length) { return null } + const act = activity.slice(-4) + const rowCount = trail.length + tools.length + act.length + const activeCotIdx = animateCot && !tools.length ? lastCotTrailIndex(trail) : -1 + return ( <> - {trail.map((line, i) => ( - - {t.brand.tool} {line} - - ))} + {trail.map((line, i) => { + const lastInBlock = i === rowCount - 1 - {tools.map(tool => ( - - {TOOL_VERBS[tool.name] ?? tool.name} - {tool.context ? `: ${tool.context}` : ''} - - ))} + if (isToolTrailResultLine(line)) { + return ( + + + {line} + + ) + } + + if (i === activeCotIdx) { + return ( + + + {line} + + ) + } + + return ( + + + {line} + + ) + })} + + {tools.map((tool, j) => { + const lastInBlock = trail.length + j === rowCount - 1 + + return ( + + + {TOOL_VERBS[tool.name] ?? tool.name} + {tool.context ? `: ${tool.context}` : ''} + + ) + })} + + {act.map((item, k) => { + const lastInBlock = trail.length + tools.length + k === rowCount - 1 + + return ( + + + {activityGlyph(item)} {item.text} + + ) + })} ) }) @@ -66,14 +130,18 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st const tail = reasoning.slice(-160).replace(/\n/g, ' ') - return tail ? ( - - 💭 {tail} - - ) : ( - - {FACES[tick % FACES.length] ?? '(•_•)'} {VERBS[tick % VERBS.length] ?? 'thinking'} - … - + return ( + <> + + {FACES[tick % FACES.length] ?? '(•_•)'}{' '} + {VERBS[tick % VERBS.length] ?? 'thinking'}… + + + {tail ? ( + + 💭 {tail} + + ) : null} + ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 2d755c3420..59fc639282 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -60,23 +60,24 @@ export const ROLE: Record { body: string; glyph: string; pre } export const TOOL_VERBS: Record = { - browser: '🌐 browsing', - clarify: '❓ asking', - create_file: '📝 creating', - delegate_task: '🤖 delegating', - delete_file: '🗑️ deleting', - execute_code: '⚡ executing', - image_generate: '🎨 generating', - list_files: '📂 listing', - memory: '🧠 remembering', - patch: '🩹 patching', - read_file: '📖 reading', - run_command: '⚙️ running', - search_code: '🔍 searching', - search_files: '🔍 searching', - terminal: '💻 terminal', - web_search: '🌐 searching', - write_file: '✏️ writing' + browser: 'browsing', + clarify: 'asking', + create_file: 'creating', + delegate_task: 'delegating', + delete_file: 'deleting', + execute_code: 'executing', + image_generate: 'generating', + list_files: 'listing', + memory: 'remembering', + patch: 'patching', + read_file: 'reading', + run_command: 'running', + search_code: 'searching', + search_files: 'searching', + terminal: 'terminal', + web_extract: 'extracting', + web_search: 'searching', + write_file: 'writing' } export const VERBS = [ diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 44d6a31522..ddb6f9fdd4 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -35,10 +35,24 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +/** Tool completed / failed row in the inline trail (not CoT prose). */ +export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') + /** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`) +/** Index of the last non-result trail line, or -1. */ +export const lastCotTrailIndex = (trail: readonly string[]) => { + for (let i = trail.length - 1; i >= 0; i--) { + if (!isToolTrailResultLine(trail[i]!)) { + return i + } + } + + return -1 +} + export const estimateRows = (text: string, w: number, compact = false) => { let inCode = false let rows = 0 From 17ecdce93690c1a946ec02b99fce9a88816413e3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 18:51:17 -0500 Subject: [PATCH 053/157] feat: add slash commands to the history so it doesnt get lost --- ui-tui/src/app.tsx | 7 +++++-- ui-tui/src/components/messageLine.tsx | 8 +++++++- ui-tui/src/components/thinking.tsx | 28 +++++++++++++++++++++------ ui-tui/src/lib/text.ts | 17 ++++++++++++++++ ui-tui/src/types.ts | 2 +- 5 files changed, 52 insertions(+), 10 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 06399ba7ad..88dcf84e64 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -709,7 +709,10 @@ export function App({ gw }: { gw: GatewayClient }) { historyDraftRef.current = '' } - if (full.startsWith('/') && slashRef.current(full)) { + if (full.startsWith('/')) { + appendMessage({ role: 'system', text: full, kind: 'slash' }) + pushHistory(full) + slashRef.current(full) clearInput() return @@ -793,7 +796,7 @@ export function App({ gw }: { gw: GatewayClient }) { send(full) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [busy, enqueue, gw, listPasteIds, pastes, resolvePasteTokens, sid] + [appendMessage, busy, enqueue, gw, listPasteIds, pastes, pushHistory, resolvePasteTokens, sid] ) // ── Input handling ─────────────────────────────────────────────── diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 91d1fe8c33..8b8b30894b 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -31,6 +31,10 @@ export const MessageLine = memo(function MessageLine({ const { body, glyph, prefix } = ROLE[msg.role](t) const content = (() => { + if (msg.kind === 'slash') { + return {msg.text} + } + if (msg.role === 'assistant') { return hasAnsi(msg.text) ? {msg.text} : } @@ -41,9 +45,11 @@ export const MessageLine = memo(function MessageLine({ return ( {head} + [long message] + {rest.join('')} ) @@ -53,7 +59,7 @@ export const MessageLine = memo(function MessageLine({ })() return ( - + {msg.thinking && ( 💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 5dbcfdab47..f4f5130eec 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -3,15 +3,21 @@ import { memo, useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { FACES, TOOL_VERBS, VERBS } from '../constants.js' -import { isToolTrailResultLine, lastCotTrailIndex } from '../lib/text.js' +import { + isToolTrailResultLine, + lastCotTrailIndex, + pick, + scaleHex, + THINKING_COT_FADE, + THINKING_COT_MAX, + thinkingCotTail +} from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool, ActivityItem } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] -const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! - const tone = (item: ActivityItem, t: Theme) => item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim @@ -128,7 +134,8 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st return () => clearInterval(id) }, []) - const tail = reasoning.slice(-160).replace(/\n/g, ' ') + const tail = thinkingCotTail(reasoning) + const clipped = reasoning.length > THINKING_COT_MAX return ( <> @@ -138,8 +145,17 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st {tail ? ( - - 💭 {tail} + + {clipped && + Array.from({ length: Math.min(THINKING_COT_FADE, tail.length) }, (_, i) => ( + + {tail[i]} + + ))} + + + {clipped ? tail.slice(THINKING_COT_FADE) : tail} + ) : null} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index ddb6f9fdd4..7f835c0cd4 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -53,6 +53,23 @@ export const lastCotTrailIndex = (trail: readonly string[]) => { return -1 } +export const THINKING_COT_MAX = 160 +export const THINKING_COT_FADE = 5 + +export const thinkingCotTail = (reasoning: string) => reasoning.replace(/\n/g, ' ').slice(-THINKING_COT_MAX) + +/** Scale #RRGGBB by k ∈ [0,1] — used for left-edge fade toward terminal bg. */ +export const scaleHex = (hex: string, k: number) => { + const h = hex.replace('#', '') + + const ch = (o: number) => + Math.round(parseInt(h.slice(o, o + 2), 16) * k) + .toString(16) + .padStart(2, '0') + + return `#${ch(0)}${ch(2)}${ch(4)}` +} + export const estimateRows = (text: string, w: number, compact = false) => { let inCode = false let rows = 0 diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 3254c2674a..1cfa035403 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -24,7 +24,7 @@ export interface ClarifyReq { export interface Msg { role: Role text: string - kind?: 'intro' + kind?: 'intro' | 'slash' info?: SessionInfo thinking?: string tools?: string[] From 4406b4b100c98526338413c2579d46eee0584cac Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Thu, 9 Apr 2026 19:53:55 -0400 Subject: [PATCH 054/157] fix: add delete support --- ui-tui/README.md | 6 +- ui-tui/package-lock.json | 2324 ++++----------------------- ui-tui/src/components/textInput.tsx | 35 +- 3 files changed, 367 insertions(+), 1998 deletions(-) diff --git a/ui-tui/README.md b/ui-tui/README.md index f9fbcd3f2d..9992cd340c 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -101,8 +101,10 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an | modified `Left/Right` | Move by word when the terminal sends `Ctrl` or `Meta` with the arrow key | | `Home` / `Ctrl+A` | Start of line | | `End` / `Ctrl+E` | End of line | -| `Backspace` / `Delete` | Delete the character to the left of the cursor | -| modified `Backspace` / `Delete` | Delete the previous word | +| `Backspace` | Delete the character to the left of the cursor | +| `Delete` | Delete the character to the right of the cursor | +| modified `Backspace` | Delete the previous word | +| modified `Delete` | Delete the next word | | `Ctrl+W` | Delete the previous word | | `Ctrl+U` | Delete from the cursor back to the start of the line | | `Ctrl+K` | Delete from the cursor to the end of the line | diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 18a63d6880..81b44fc537 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -33,8 +33,6 @@ }, "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", @@ -44,22 +42,8 @@ "node": ">=18" } }, - "node_modules/@alcalzone/ansi-tokenize/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/@babel/code-frame": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -73,8 +57,6 @@ }, "node_modules/@babel/compat-data": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -83,10 +65,9 @@ }, "node_modules/@babel/core": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -114,8 +95,6 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -124,8 +103,6 @@ }, "node_modules/@babel/generator": { "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { @@ -141,8 +118,6 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { @@ -158,8 +133,6 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -168,8 +141,6 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", "engines": { @@ -178,8 +149,6 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { @@ -192,8 +161,6 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { @@ -210,8 +177,6 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -220,8 +185,6 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -230,8 +193,6 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -240,8 +201,6 @@ }, "node_modules/@babel/helpers": { "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { @@ -254,8 +213,6 @@ }, "node_modules/@babel/parser": { "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -270,8 +227,6 @@ }, "node_modules/@babel/template": { "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { @@ -285,8 +240,6 @@ }, "node_modules/@babel/traverse": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { @@ -304,8 +257,6 @@ }, "node_modules/@babel/types": { "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -316,316 +267,8 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.27.5", "cpu": [ "x64" ], @@ -639,163 +282,8 @@ "node": ">=18" } }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", - "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -811,10 +299,19 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -823,8 +320,6 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", - "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -836,28 +331,8 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/config-array/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -867,10 +342,22 @@ "node": "*" } }, + "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -882,8 +369,6 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -895,8 +380,6 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", - "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { @@ -917,28 +400,8 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -950,8 +413,6 @@ }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -960,8 +421,6 @@ }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -971,10 +430,22 @@ "node": "*" } }, + "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, "node_modules/@eslint/js": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", - "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -986,8 +457,6 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -996,8 +465,6 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1010,8 +477,6 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1020,8 +485,6 @@ }, "node_modules/@humanfs/node": { "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1034,8 +497,6 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1048,8 +509,6 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1062,8 +521,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -1073,8 +530,6 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1084,8 +539,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { @@ -1094,15 +547,11 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -1110,192 +559,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "version": "0.123.0", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", - "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", - "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", - "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "version": "1.0.0-rc.13", "cpu": [ "x64" ], @@ -1310,9 +583,7 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", - "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "version": "1.0.0-rc.13", "cpu": [ "x64" ], @@ -1326,105 +597,18 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", - "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", - "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.9.2", - "@emnapi/runtime": "1.9.2", - "@napi-rs/wasm-runtime": "^1.1.3" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", - "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", - "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "version": "1.0.0-rc.13", "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -1434,57 +618,48 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.5.0", "dev": true, "license": "MIT", + "peer": true, "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==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", - "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "version": "8.58.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/type-utils": "8.58.1", - "@typescript-eslint/utils": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -1497,22 +672,21 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.1", + "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", - "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "version": "8.58.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "engines": { @@ -1528,14 +702,12 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", - "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "version": "8.58.0", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.1", - "@typescript-eslint/types": "^8.58.1", + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "engines": { @@ -1550,14 +722,12 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", - "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "version": "8.58.0", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1" + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1568,9 +738,7 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", - "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "version": "8.58.0", "dev": true, "license": "MIT", "engines": { @@ -1585,15 +753,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", - "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "version": "8.58.0", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1", - "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -1610,9 +776,7 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", - "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "version": "8.58.0", "dev": true, "license": "MIT", "engines": { @@ -1624,16 +788,14 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", - "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "version": "8.58.0", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.1", - "@typescript-eslint/tsconfig-utils": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/visitor-keys": "8.58.1", + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -1651,17 +813,59 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.5", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", - "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "version": "8.58.0", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.1", - "@typescript-eslint/types": "8.58.1", - "@typescript-eslint/typescript-estree": "8.58.1" + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1676,13 +880,11 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", - "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "version": "8.58.0", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1695,8 +897,6 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", - "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1707,16 +907,14 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", - "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", + "version": "4.1.3", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1725,13 +923,11 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", - "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", + "version": "4.1.3", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.4", + "@vitest/spy": "4.1.3", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1752,9 +948,7 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", - "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", + "version": "4.1.3", "dev": true, "license": "MIT", "dependencies": { @@ -1765,13 +959,11 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", - "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", + "version": "4.1.3", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.4", + "@vitest/utils": "4.1.3", "pathe": "^2.0.3" }, "funding": { @@ -1779,14 +971,12 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", - "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", + "version": "4.1.3", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/pretty-format": "4.1.3", + "@vitest/utils": "4.1.3", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1795,9 +985,7 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", - "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", + "version": "4.1.3", "dev": true, "license": "MIT", "funding": { @@ -1805,13 +993,11 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", - "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", + "version": "4.1.3", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.4", + "@vitest/pretty-format": "4.1.3", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1821,10 +1007,9 @@ }, "node_modules/acorn": { "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1834,8 +1019,6 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1844,8 +1027,6 @@ }, "node_modules/ajv": { "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1861,8 +1042,6 @@ }, "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" @@ -1876,8 +1055,6 @@ }, "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" @@ -1887,16 +1064,10 @@ } }, "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "version": "6.2.3", "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -1904,15 +1075,11 @@ }, "node_modules/argparse": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -1928,8 +1095,6 @@ }, "node_modules/array-includes": { "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1951,8 +1116,6 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1972,8 +1135,6 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -1991,8 +1152,6 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -2010,8 +1169,6 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", - "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -2027,8 +1184,6 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2049,8 +1204,6 @@ }, "node_modules/assertion-error": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -2059,8 +1212,6 @@ }, "node_modules/async-function": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", "engines": { @@ -2069,8 +1220,6 @@ }, "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" @@ -2081,8 +1230,6 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2095,20 +1242,8 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/baseline-browser-mapping": { - "version": "2.10.17", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", - "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", + "version": "2.10.13", "dev": true, "license": "Apache-2.0", "bin": { @@ -2118,23 +1253,8 @@ "node": ">=6.0.0" } }, - "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, "node_modules/browserslist": { "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -2151,6 +1271,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2166,15 +1287,13 @@ } }, "node_modules/call-bind": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "version": "1.0.8", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "get-intrinsic": "^1.3.0", + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" }, "engines": { @@ -2186,8 +1305,6 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2200,8 +1317,6 @@ }, "node_modules/call-bound": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { @@ -2217,8 +1332,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -2226,9 +1339,7 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001784", "dev": true, "funding": [ { @@ -2248,35 +1359,14 @@ }, "node_modules/chai": { "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "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" @@ -2287,8 +1377,6 @@ }, "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" @@ -2302,8 +1390,6 @@ }, "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", @@ -2316,10 +1402,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.0", + "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" @@ -2330,8 +1428,6 @@ }, "node_modules/color-convert": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2343,29 +1439,21 @@ }, "node_modules/color-name": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "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" @@ -2373,8 +1461,6 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -2388,15 +1474,11 @@ }, "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==", "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2413,8 +1495,6 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2431,8 +1511,6 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2449,8 +1527,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -2467,15 +1543,11 @@ }, "node_modules/deep-is": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/define-data-property": { "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -2492,8 +1564,6 @@ }, "node_modules/define-properties": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -2510,8 +1580,6 @@ }, "node_modules/detect-libc": { "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2520,8 +1588,6 @@ }, "node_modules/doctrine": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2533,8 +1599,6 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { @@ -2547,22 +1611,16 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.334", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", - "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "version": "1.5.331", "dev": true, "license": "ISC" }, "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" @@ -2572,9 +1630,7 @@ } }, "node_modules/es-abstract": { - "version": "1.24.2", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", - "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "version": "1.24.1", "dev": true, "license": "MIT", "dependencies": { @@ -2642,8 +1698,6 @@ }, "node_modules/es-define-property": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { @@ -2652,8 +1706,6 @@ }, "node_modules/es-errors": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", "engines": { @@ -2662,8 +1714,6 @@ }, "node_modules/es-iterator-helpers": { "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", - "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2691,15 +1741,11 @@ }, "node_modules/es-module-lexer": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -2711,8 +1757,6 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { @@ -2727,8 +1771,6 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -2740,8 +1782,6 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { @@ -2758,8 +1798,6 @@ }, "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", @@ -2767,9 +1805,7 @@ ] }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.27.5", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2780,63 +1816,47 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@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/escalade": { "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint": { "version": "9.39.4", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", - "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2893,8 +1913,6 @@ }, "node_modules/eslint-plugin-perfectionist": { "version": "5.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.8.0.tgz", - "integrity": "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==", "dev": true, "license": "MIT", "dependencies": { @@ -2910,8 +1928,6 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", - "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -2943,8 +1959,6 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", - "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", "dependencies": { @@ -2961,28 +1975,8 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint-plugin-react/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2992,10 +1986,22 @@ "node": "*" } }, + "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion": { + "version": "1.1.13", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -3004,8 +2010,6 @@ }, "node_modules/eslint-plugin-unused-imports": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", - "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3020,8 +2024,6 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3035,30 +2037,27 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "node_modules/eslint/node_modules/ansi-styles": { + "version": "4.3.0", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": ">=8" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3066,10 +2065,34 @@ "concat-map": "0.0.1" } }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3081,8 +2104,6 @@ }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -3091,8 +2112,6 @@ }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3104,8 +2123,6 @@ }, "node_modules/espree": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3122,8 +2139,6 @@ }, "node_modules/espree/node_modules/eslint-visitor-keys": { "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3135,8 +2150,6 @@ }, "node_modules/esquery": { "version": "1.7.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", - "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3148,8 +2161,6 @@ }, "node_modules/esrecurse": { "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3161,8 +2172,6 @@ }, "node_modules/estraverse": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3171,8 +2180,6 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -3181,8 +2188,6 @@ }, "node_modules/esutils": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -3191,8 +2196,6 @@ }, "node_modules/expect-type": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -3201,29 +2204,21 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -3240,8 +2235,6 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3253,8 +2246,6 @@ }, "node_modules/find-up": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -3270,8 +2261,6 @@ }, "node_modules/flat-cache": { "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -3284,15 +2273,11 @@ }, "node_modules/flatted": { "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -3305,25 +2290,8 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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/function-bind": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -3332,8 +2300,6 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3353,8 +2319,6 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { @@ -3363,8 +2327,6 @@ }, "node_modules/generator-function": { "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "dev": true, "license": "MIT", "engines": { @@ -3373,8 +2335,6 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { @@ -3383,8 +2343,6 @@ }, "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" @@ -3395,8 +2353,6 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3420,8 +2376,6 @@ }, "node_modules/get-proto": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { @@ -3434,8 +2388,6 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { @@ -3452,8 +2404,6 @@ }, "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": { @@ -3465,8 +2415,6 @@ }, "node_modules/glob-parent": { "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -3478,8 +2426,6 @@ }, "node_modules/globals": { "version": "16.5.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", - "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -3491,8 +2437,6 @@ }, "node_modules/globalthis": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3508,8 +2452,6 @@ }, "node_modules/gopd": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", "engines": { @@ -3521,8 +2463,6 @@ }, "node_modules/has-bigints": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -3534,8 +2474,6 @@ }, "node_modules/has-flag": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -3544,8 +2482,6 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -3557,8 +2493,6 @@ }, "node_modules/has-proto": { "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3573,8 +2507,6 @@ }, "node_modules/has-symbols": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -3586,8 +2518,6 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { @@ -3602,8 +2532,6 @@ }, "node_modules/hasown": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3615,15 +2543,11 @@ }, "node_modules/hermes-estree": { "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", - "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", "dev": true, "license": "MIT" }, "node_modules/hermes-parser": { "version": "0.25.1", - "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", - "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", "dev": true, "license": "MIT", "dependencies": { @@ -3632,8 +2556,6 @@ }, "node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -3642,8 +2564,6 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3659,8 +2579,6 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -3669,8 +2587,6 @@ }, "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" @@ -3681,8 +2597,6 @@ }, "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", @@ -3730,8 +2644,6 @@ }, "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", @@ -3747,8 +2659,6 @@ }, "node_modules/ink-text-input/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" @@ -3759,8 +2669,6 @@ }, "node_modules/ink-text-input/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" @@ -3769,22 +2677,8 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ink/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/ink/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" @@ -3793,10 +2687,35 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/ink/node_modules/string-width": { + "version": "8.2.0", + "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", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/internal-slot": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -3810,8 +2729,6 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -3828,8 +2745,6 @@ }, "node_modules/is-async-function": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3848,8 +2763,6 @@ }, "node_modules/is-bigint": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3864,8 +2777,6 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -3881,8 +2792,6 @@ }, "node_modules/is-callable": { "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -3894,8 +2803,6 @@ }, "node_modules/is-core-module": { "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -3910,8 +2817,6 @@ }, "node_modules/is-data-view": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { @@ -3928,8 +2833,6 @@ }, "node_modules/is-date-object": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -3945,8 +2848,6 @@ }, "node_modules/is-extglob": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -3955,8 +2856,6 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { @@ -3971,8 +2870,6 @@ }, "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" @@ -3986,8 +2883,6 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { @@ -4006,8 +2901,6 @@ }, "node_modules/is-glob": { "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -4019,8 +2912,6 @@ }, "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" @@ -4034,8 +2925,6 @@ }, "node_modules/is-map": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -4047,8 +2936,6 @@ }, "node_modules/is-negative-zero": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { @@ -4060,8 +2947,6 @@ }, "node_modules/is-number-object": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -4077,8 +2962,6 @@ }, "node_modules/is-regex": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -4096,8 +2979,6 @@ }, "node_modules/is-set": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -4109,8 +2990,6 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -4125,8 +3004,6 @@ }, "node_modules/is-string": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -4142,8 +3019,6 @@ }, "node_modules/is-symbol": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4160,8 +3035,6 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4176,8 +3049,6 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -4189,8 +3060,6 @@ }, "node_modules/is-weakref": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { @@ -4205,8 +3074,6 @@ }, "node_modules/is-weakset": { "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4222,22 +3089,16 @@ }, "node_modules/isarray": { "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { "version": "1.1.5", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", - "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -4254,15 +3115,11 @@ }, "node_modules/js-tokens": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -4274,8 +3131,6 @@ }, "node_modules/jsesc": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -4287,29 +3142,21 @@ }, "node_modules/json-buffer": { "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", "bin": { @@ -4321,8 +3168,6 @@ }, "node_modules/jsx-ast-utils": { "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4337,8 +3182,6 @@ }, "node_modules/keyv": { "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -4347,8 +3190,6 @@ }, "node_modules/levn": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4361,8 +3202,6 @@ }, "node_modules/lightningcss": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -4389,157 +3228,8 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -4559,8 +3249,6 @@ }, "node_modules/lightningcss-linux-x64-musl": { "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -4578,52 +3266,8 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/locate-path": { "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -4638,15 +3282,11 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4658,8 +3298,6 @@ }, "node_modules/lru-cache": { "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -4668,8 +3306,6 @@ }, "node_modules/magic-string": { "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4678,8 +3314,6 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { @@ -4688,40 +3322,18 @@ }, "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/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ms": { "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -4739,15 +3351,11 @@ }, "node_modules/natural-compare": { "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/natural-orderby": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", - "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", "dev": true, "license": "MIT", "engines": { @@ -4756,8 +3364,6 @@ }, "node_modules/node-exports-info": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", - "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", "dev": true, "license": "MIT", "dependencies": { @@ -4775,8 +3381,6 @@ }, "node_modules/node-exports-info/node_modules/semver": { "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -4785,15 +3389,11 @@ }, "node_modules/node-releases": { "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", "engines": { @@ -4802,8 +3402,6 @@ }, "node_modules/object-inspect": { "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -4815,8 +3413,6 @@ }, "node_modules/object-keys": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -4825,8 +3421,6 @@ }, "node_modules/object.assign": { "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -4846,8 +3440,6 @@ }, "node_modules/object.entries": { "version": "1.1.9", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", - "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { @@ -4862,8 +3454,6 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4881,8 +3471,6 @@ }, "node_modules/object.values": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -4900,8 +3488,6 @@ }, "node_modules/obug": { "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -4911,8 +3497,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" @@ -4926,8 +3510,6 @@ }, "node_modules/optionator": { "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -4944,8 +3526,6 @@ }, "node_modules/own-keys": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, "license": "MIT", "dependencies": { @@ -4962,8 +3542,6 @@ }, "node_modules/p-limit": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4978,8 +3556,6 @@ }, "node_modules/p-locate": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -4994,8 +3570,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -5007,8 +3581,6 @@ }, "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" @@ -5016,8 +3588,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -5026,8 +3596,6 @@ }, "node_modules/path-key": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -5036,31 +3604,24 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, "license": "MIT" }, "node_modules/pathe": { "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5070,8 +3631,6 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -5080,8 +3639,6 @@ }, "node_modules/postcss": { "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -5109,8 +3666,6 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -5119,8 +3674,6 @@ }, "node_modules/prettier": { "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -5135,8 +3688,6 @@ }, "node_modules/prop-types": { "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, "license": "MIT", "dependencies": { @@ -5147,8 +3698,6 @@ }, "node_modules/punycode": { "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -5156,25 +3705,20 @@ } }, "node_modules/react": { - "version": "19.2.5", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", - "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "version": "19.2.4", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-is": { "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true, "license": "MIT" }, "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" @@ -5188,8 +3732,6 @@ }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { @@ -5211,8 +3753,6 @@ }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -5232,8 +3772,6 @@ }, "node_modules/resolve": { "version": "2.0.0-next.6", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", - "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { @@ -5256,8 +3794,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -5266,8 +3802,6 @@ }, "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": { @@ -5276,8 +3810,6 @@ }, "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", @@ -5291,14 +3823,12 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.15", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", - "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "version": "1.0.0-rc.13", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.124.0", - "@rolldown/pluginutils": "1.0.0-rc.15" + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" }, "bin": { "rolldown": "bin/cli.mjs" @@ -5307,27 +3837,25 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", - "@rolldown/binding-darwin-x64": "1.0.0-rc.15", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" } }, "node_modules/safe-array-concat": { "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -5346,8 +3874,6 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { @@ -5363,8 +3889,6 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -5381,27 +3905,10 @@ }, "node_modules/scheduler": { "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/set-function-length": { "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -5418,8 +3925,6 @@ }, "node_modules/set-function-name": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5434,8 +3939,6 @@ }, "node_modules/set-proto": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { @@ -5449,8 +3952,6 @@ }, "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -5462,8 +3963,6 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -5472,8 +3971,6 @@ }, "node_modules/side-channel": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { @@ -5491,14 +3988,12 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "version": "1.0.0", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" + "object-inspect": "^1.13.3" }, "engines": { "node": ">= 0.4" @@ -5509,8 +4004,6 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { @@ -5528,8 +4021,6 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { @@ -5548,21 +4039,15 @@ }, "node_modules/siginfo": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "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", @@ -5575,22 +4060,8 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/slice-ansi/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/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -5599,8 +4070,6 @@ }, "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" @@ -5611,8 +4080,6 @@ }, "node_modules/stack-utils/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" @@ -5620,22 +4087,16 @@ }, "node_modules/stackback": { "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "4.0.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", - "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5646,26 +4107,8 @@ "node": ">= 0.4" } }, - "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/string.prototype.matchall": { "version": "4.0.12", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", - "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, "license": "MIT", "dependencies": { @@ -5692,8 +4135,6 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", - "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -5703,8 +4144,6 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { @@ -5725,8 +4164,6 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5744,8 +4181,6 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { @@ -5762,8 +4197,6 @@ }, "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" @@ -5777,8 +4210,6 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -5790,8 +4221,6 @@ }, "node_modules/supports-color": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -5803,8 +4232,6 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { @@ -5816,8 +4243,6 @@ }, "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" @@ -5828,8 +4253,6 @@ }, "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" @@ -5840,15 +4263,11 @@ }, "node_modules/tinybench": { "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -5856,14 +4275,12 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.15", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.4" + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -5874,8 +4291,6 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -5884,8 +4299,6 @@ }, "node_modules/ts-api-utils": { "version": "2.5.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", - "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -5895,20 +4308,11 @@ "typescript": ">=4.8.4" } }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "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", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -5925,8 +4329,6 @@ }, "node_modules/type-check": { "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -5936,25 +4338,8 @@ "node": ">= 0.8.0" } }, - "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/typed-array-buffer": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { @@ -5968,8 +4353,6 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { @@ -5988,8 +4371,6 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6010,8 +4391,6 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -6031,10 +4410,9 @@ }, "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", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6045,8 +4423,6 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { @@ -6064,15 +4440,11 @@ }, "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/unicode-animations": { "version": "1.0.3", - "resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz", - "integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -6084,8 +4456,6 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -6115,8 +4485,6 @@ }, "node_modules/uri-js": { "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -6124,16 +4492,15 @@ } }, "node_modules/vite": { - "version": "8.0.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", - "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "version": "8.0.7", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.15", + "rolldown": "1.0.0-rc.13", "tinyglobby": "^0.2.15" }, "bin": { @@ -6202,19 +4569,17 @@ } }, "node_modules/vitest": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", - "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", + "version": "4.1.3", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.4", - "@vitest/mocker": "4.1.4", - "@vitest/pretty-format": "4.1.4", - "@vitest/runner": "4.1.4", - "@vitest/snapshot": "4.1.4", - "@vitest/spy": "4.1.4", - "@vitest/utils": "4.1.4", + "@vitest/expect": "4.1.3", + "@vitest/mocker": "4.1.3", + "@vitest/pretty-format": "4.1.3", + "@vitest/runner": "4.1.3", + "@vitest/snapshot": "4.1.3", + "@vitest/spy": "4.1.3", + "@vitest/utils": "4.1.3", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -6242,12 +4607,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.4", - "@vitest/browser-preview": "4.1.4", - "@vitest/browser-webdriverio": "4.1.4", - "@vitest/coverage-istanbul": "4.1.4", - "@vitest/coverage-v8": "4.1.4", - "@vitest/ui": "4.1.4", + "@vitest/browser-playwright": "4.1.3", + "@vitest/browser-preview": "4.1.3", + "@vitest/browser-webdriverio": "4.1.3", + "@vitest/coverage-istanbul": "4.1.3", + "@vitest/coverage-v8": "4.1.3", + "@vitest/ui": "4.1.3", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -6293,8 +4658,6 @@ }, "node_modules/which": { "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -6309,8 +4672,6 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -6329,8 +4690,6 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -6357,8 +4716,6 @@ }, "node_modules/which-collection": { "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -6376,8 +4733,6 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -6398,8 +4753,6 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -6415,8 +4768,6 @@ }, "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" @@ -6428,10 +4779,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/widest-line/node_modules/string-width": { + "version": "8.2.0", + "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/word-wrap": { "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -6440,8 +4803,6 @@ }, "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", @@ -6455,22 +4816,8 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi/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/wrap-ansi/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -6486,8 +4833,6 @@ }, "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" @@ -6507,15 +4852,11 @@ }, "node_modules/yallist": { "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, "node_modules/yocto-queue": { "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { @@ -6527,24 +4868,19 @@ }, "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" }, "node_modules/zod": { "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-validation-error": { "version": "4.0.2", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", - "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, "license": "MIT", "engines": { diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 9ec083c9dd..f5deb9c49c 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1,4 +1,4 @@ -import { Text, useInput } from 'ink' +import { Text, useInput, useStdin } from 'ink' import { useEffect, useRef, useState } from 'react' function wordLeft(s: string, p: number) { @@ -29,6 +29,29 @@ function wordRight(s: string, p: number) { return i } +const FWD_DELETE_RE = /\x1b\[3[~$^]|\x1b\[3;/ + +function useForwardDeleteRef(isActive: boolean) { + const ref = useRef(false) + const { internal_eventEmitter: ee } = useStdin() + + useEffect(() => { + if (!isActive) return + + const onInput = (data: string) => { + ref.current = FWD_DELETE_RE.test(data) + } + + ee.prependListener('input', onInput) + + return () => { + ee.removeListener('input', onInput) + } + }, [isActive, ee]) + + return ref +} + const ESC = '\x1b' const INV = ESC + '[7m' const INV_OFF = ESC + '[27m' @@ -56,6 +79,7 @@ interface Props { export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { const [cur, setCur] = useState(value.length) + const isFwdDelete = useForwardDeleteRef(focus) const curRef = useRef(cur) const vRef = useRef(value) @@ -211,7 +235,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' c = mod ? wordLeft(v, c) : Math.max(0, c - 1) } else if (k.rightArrow) { c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) - } else if ((k.backspace || k.delete) && c > 0) { + } else if ((k.backspace || k.delete) && !isFwdDelete.current && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -220,6 +244,13 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' v = v.slice(0, c - 1) + v.slice(c) c-- } + } else if (k.delete && isFwdDelete.current && c < v.length) { + if (mod) { + const t = wordRight(v, c) + v = v.slice(0, c) + v.slice(t) + } else { + v = v.slice(0, c) + v.slice(c + 1) + } } else if (k.ctrl && inp === 'w' && c > 0) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) From b85ff282bcd850b242b024c96f7de0cd1ea99b4c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 19:08:47 -0500 Subject: [PATCH 055/157] feat(ui-tui): slash command history/display, CoT fade, live skin switch, fix double reasoning --- hermes_cli/commands.py | 31 ++++++++++++++++++++++++++----- run_agent.py | 2 +- tui_gateway/server.py | 6 +++++- ui-tui/src/app.tsx | 18 +++++++++++++++++- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 917e8b1e02..a679817b38 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -104,7 +104,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[level|show|hide]", subcommands=("none", "low", "minimal", "medium", "high", "xhigh", "show", "hide", "on", "off")), CommandDef("skin", "Show or change the display skin/theme", "Configuration", - cli_only=True, args_hint="[name]"), + args_hint="[name]"), CommandDef("voice", "Toggle voice mode", "Configuration", args_hint="[on|off|tts|status]", subcommands=("on", "off", "tts", "status")), @@ -861,6 +861,23 @@ class SlashCommandCompleter(Completer): ) count += 1 + @staticmethod + def _skin_completions(sub_text: str, sub_lower: str): + """Yield completions for /skin from available skins.""" + try: + from hermes_cli.skin_engine import list_skins + for s in list_skins(): + name = s["name"] + if name.startswith(sub_lower) and name != sub_lower: + yield Completion( + name, + start_position=-len(sub_text), + display=name, + display_meta=s.get("description", "") or s.get("source", ""), + ) + except Exception: + pass + def _model_completions(self, sub_text: str, sub_lower: str): """Yield completions for /model from config aliases + built-in aliases.""" seen = set() @@ -915,10 +932,14 @@ class SlashCommandCompleter(Completer): sub_text = parts[1] if len(parts) > 1 else "" sub_lower = sub_text.lower() - # Dynamic model alias completions for /model - if " " not in sub_text and base_cmd == "/model": - yield from self._model_completions(sub_text, sub_lower) - return + # Dynamic completions for commands with runtime lists + if " " not in sub_text: + if base_cmd == "/model": + yield from self._model_completions(sub_text, sub_lower) + return + if base_cmd == "/skin": + yield from self._skin_completions(sub_text, sub_lower) + return # Static subcommand completions if " " not in sub_text and base_cmd in SUBCOMMANDS: diff --git a/run_agent.py b/run_agent.py index d7234f2964..499843585c 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5722,7 +5722,7 @@ class AIAgent: # (gateway, batch, quiet) still get reasoning. # Any reasoning that wasn't shown during streaming is caught by the # CLI post-response display fallback (cli.py _reasoning_shown_this_turn). - if not self.stream_delta_callback: + if not self.stream_delta_callback and not self._stream_callback: try: self.reasoning_callback(reasoning_text) except Exception: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f0b80ad502..023da60b1a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -121,7 +121,7 @@ def write_json(obj: dict) -> bool: def _emit(event: str, sid: str, payload: dict | None = None): params = {"type": event, "session_id": sid} - if payload: + if payload is not None: params["payload"] = payload write_json({"jsonrpc": "2.0", "method": "event", "params": params}) @@ -842,6 +842,8 @@ def _(rid, params: dict) -> dict: cfg.setdefault("display", {})[key] = value nv = value _save_cfg(cfg) + if key == "skin": + _emit("skin.changed", "", resolve_skin()) return _ok(rid, {"key": key, "value": nv}) except Exception as e: return _err(rid, 5001, str(e)) @@ -868,6 +870,8 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"config": _load_cfg()}) if key == "prompt": return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) + if key == "skin": + return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) return _err(rid, 4002, f"unknown config key: {key}") diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 88dcf84e64..255a62d0ef 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -840,7 +840,7 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (completions.length && input && (key.upArrow || key.downArrow)) { + if (completions.length && input && historyIdx === null && (key.upArrow || key.downArrow)) { setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)) return @@ -1004,6 +1004,13 @@ export function App({ gw }: { gw: GatewayClient }) { break + case 'skin.changed': + if (p) { + setTheme(fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')) + } + + break + case 'session.info': setInfo(p as SessionInfo) @@ -1520,6 +1527,15 @@ export function App({ gw }: { gw: GatewayClient }) { return true + case 'skin': + if (arg) { + rpc('config.set', { key: 'skin', value: arg }).then((r: any) => sys(`skin → ${r.value}`)) + } else { + rpc('config.get', { key: 'skin' }).then((r: any) => sys(`skin: ${r.value || 'default'}`)) + } + + return true + case 'yolo': rpc('config.set', { key: 'yolo' }).then((r: any) => sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)) From 4fe78d5b88868e9ad15a68a656933d196f96236a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 19:17:06 -0500 Subject: [PATCH 056/157] chore: fix bad merge apparently? --- cli.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/cli.py b/cli.py index 6303e54f7c..237ed78998 100644 --- a/cli.py +++ b/cli.py @@ -3214,7 +3214,6 @@ class HermesCLI: else: _cprint(f" {_DIM}(._.) No image found in clipboard{_RST}") -<<<<<<< HEAD def _write_osc52_clipboard(self, text: str) -> None: """Copy *text* to terminal clipboard via OSC 52.""" payload = base64.b64encode(text.encode("utf-8")).decode("ascii") @@ -3270,8 +3269,6 @@ class HermesCLI: except Exception as e: _cprint(f" Clipboard copy failed: {e}") - def _preprocess_images_with_vision(self, text: str, images: list) -> str: -======= def _handle_image_command(self, cmd_original: str): """Handle /image — attach a local image file for the next prompt.""" raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "") @@ -3297,7 +3294,6 @@ class HermesCLI: _cprint(f" {_DIM}Tip: type your next message, or run hermes chat -q --image {_termux_example_image_path(image_path.name)} \"What do you see?\"{_RST}") def _preprocess_images_with_vision(self, text: str, images: list, *, announce: bool = True) -> str: ->>>>>>> main """Analyze attached images via the vision tool and return enriched text. Instead of embedding raw base64 ``image_url`` content parts in the From e1df13cf2015c732d0c1b47a6f9f1426929d9848 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Fri, 10 Apr 2026 00:01:37 -0400 Subject: [PATCH 057/157] fix: menus --- hermes_cli/skills_hub.py | 17 +++-- tui_gateway/server.py | 67 ++++++++++++++++---- ui-tui/src/app.tsx | 134 ++++++++++++++++++++++++++++++++++----- ui-tui/src/types.ts | 7 ++ 4 files changed, 193 insertions(+), 32 deletions(-) diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 0ecb677fcf..fa4981c1ab 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -496,8 +496,11 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: c.print() -def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> list[dict]: - """Paginated hub browse for programmatic callers (e.g. TUI gateway).""" +def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> dict: + """Paginated hub browse for programmatic callers (e.g. TUI gateway). + + Returns ``{"items": [...], "page": int, "total_pages": int, "total": int}``. + """ from tools.skills_hub import GitHubAuth, create_source_router page_size = max(1, min(page_size, 100)) @@ -517,7 +520,7 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> li except Exception: continue if not all_results: - return [] + return {"items": [], "page": 1, "total_pages": 1, "total": 0} seen: dict = {} for r in all_results: rank = _TRUST_RANK.get(r.trust_level, 0) @@ -530,7 +533,13 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> li page = max(1, min(page, total_pages)) start = (page - 1) * page_size page_items = deduped[start : min(start + page_size, total)] - return [{"name": r.name, "description": r.description} for r in page_items] + return { + "items": [{"name": r.name, "description": r.description, "source": r.source, + "trust": r.trust_level} for r in page_items], + "page": page, + "total_pages": total_pages, + "total": total, + } def inspect_skill(identifier: str) -> Optional[dict]: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 023da60b1a..c204e19047 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -903,31 +903,74 @@ def _(rid, params: dict) -> dict: return _err(rid, 5015, str(e)) +_TUI_HIDDEN: frozenset[str] = frozenset({ + "sethome", "set-home", "update", "commands", "status", "approve", "deny", +}) + +_TUI_EXTRA: list[tuple[str, str, str]] = [ + ("/compact", "Toggle compact display mode", "TUI"), + ("/logs", "Show recent gateway log lines", "TUI"), +] + + @method("commands.catalog") def _(rid, params: dict) -> dict: - """Registry-backed slash metadata (same surface as SlashCommandCompleter).""" + """Registry-backed slash metadata for the TUI — categorized, no aliases.""" try: - from hermes_cli.commands import COMMAND_REGISTRY, COMMANDS, SUBCOMMANDS + from hermes_cli.commands import COMMAND_REGISTRY, SUBCOMMANDS, _build_description - pairs = sorted(COMMANDS.items(), key=lambda kv: kv[0]) - sub = {k: v[:] for k, v in SUBCOMMANDS.items()} + all_pairs: list[list[str]] = [] canon: dict[str, str] = {} + categories: list[dict] = [] + cat_map: dict[str, list[list[str]]] = {} + cat_order: list[str] = [] + for cmd in COMMAND_REGISTRY: - if cmd.gateway_only: - continue c = f"/{cmd.name}" canon[c.lower()] = c for a in cmd.aliases: canon[f"/{a}".lower()] = c - skills = [] + + if cmd.name in _TUI_HIDDEN: + continue + + desc = _build_description(cmd) + all_pairs.append([c, desc]) + + cat = cmd.category + if cat not in cat_map: + cat_map[cat] = [] + cat_order.append(cat) + cat_map[cat].append([c, desc]) + + for name, desc, cat in _TUI_EXTRA: + all_pairs.append([name, desc]) + if cat not in cat_map: + cat_map[cat] = [] + cat_order.append(cat) + cat_map[cat].append([name, desc]) + + skill_count = 0 try: from agent.skill_commands import scan_skill_commands - for k, info in scan_skill_commands().items(): + for k, info in sorted(scan_skill_commands().items()): d = str(info.get("description", "Skill")) - skills.append([k, f"⚡ {d[:120]}{'…' if len(d) > 120 else ''}"]) + all_pairs.append([k, d[:120] + ("…" if len(d) > 120 else "")]) + skill_count += 1 except Exception: pass - return _ok(rid, {"pairs": pairs + skills, "sub": sub, "canon": canon}) + + for cat in cat_order: + categories.append({"name": cat, "pairs": cat_map[cat]}) + + sub = {k: v[:] for k, v in SUBCOMMANDS.items()} + return _ok(rid, { + "pairs": all_pairs, + "sub": sub, + "canon": canon, + "categories": categories, + "skill_count": skill_count, + }) except Exception as e: return _err(rid, 5020, str(e)) @@ -1416,8 +1459,8 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"installed": True, "name": query}) if action == "browse": from hermes_cli.skills_hub import browse_skills - return _ok(rid, {"results": [{"name": r.get("name", ""), "description": r.get("description", "")} - for r in (browse_skills(page=int(query) if query.isdigit() else 1) or [])]}) + pg = int(params.get("page", 0) or 0) or (int(query) if query.isdigit() else 1) + return _ok(rid, browse_skills(page=pg, page_size=int(params.get("page_size", 20)))) if action == "inspect": from hermes_cli.skills_hub import inspect_skill return _ok(rid, {"info": inspect_skill(query) or {}}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 255a62d0ef..25c87205c5 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -280,6 +280,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) + const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null) // ── Refs ───────────────────────────────────────────────────────── @@ -312,7 +313,7 @@ export function App({ gw }: { gw: GatewayClient }) { const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) function blocked() { - return !!(clarify || approval || pasteReview || picker || secret || sudo) + return !!(clarify || approval || pasteReview || picker || secret || sudo || pager) } const empty = !messages.length @@ -349,6 +350,11 @@ export function App({ gw }: { gw: GatewayClient }) { const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) + const page = useCallback((text: string) => { + const lines = text.split('\n') + setPager({ lines, offset: 0 }) + }, []) + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(prev => { const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev @@ -803,8 +809,26 @@ export function App({ gw }: { gw: GatewayClient }) { const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target + const pagerPageSize = Math.max(5, (stdout?.rows ?? 24) - 6) + useInput((ch, key) => { if (isBlocked) { + if (pager) { + if (key.return || ch === ' ') { + const next = pager.offset + pagerPageSize + + if (next >= pager.lines.length) { + setPager(null) + } else { + setPager({ ...pager, offset: next }) + } + } else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') { + setPager(null) + } + + return + } + if (pasteReview) { if (key.return) { setPasteReview(null) @@ -970,7 +994,9 @@ export function App({ gw }: { gw: GatewayClient }) { setCatalog({ canon: (r.canon ?? {}) as Record, + categories: (r.categories ?? []) as SlashCatalog['categories'], pairs: r.pairs as [string, string][], + skillCount: (r.skill_count ?? 0) as number, sub: (r.sub ?? {}) as Record }) }) @@ -1272,21 +1298,26 @@ export function App({ gw }: { gw: GatewayClient }) { switch (name) { case 'help': { - const rows = catalog?.pairs ?? [] - const cap = 52 + const cats = catalog?.categories ?? [] + const skills = catalog?.skillCount ?? 0 + const lines: string[] = [] - sys( - [ - ' Commands:', - ...rows.slice(0, cap).map(([c, d]) => ` ${c.padEnd(16)} ${d}`), - rows.length > cap ? ` … ${rows.length - cap} more` : '', - '', - ' Hotkeys:', - ...HOTKEYS.map(([k, d]) => ` ${k.padEnd(14)} ${d}`) - ] - .filter(Boolean) - .join('\n') - ) + for (const { name: catName, pairs } of cats) { + if (lines.length) lines.push('') + lines.push(` ${catName}:`) + for (const [c, d] of pairs) lines.push(` ${c.padEnd(18)} ${d}`) + } + + if (!lines.length) lines.push(' (no commands loaded)') + + if (skills > 0) { + lines.push('', ` ${skills} skill commands available — /skills to browse`) + } + + lines.push('', ' Hotkeys:') + for (const [k, d] of HOTKEYS) lines.push(` ${k.padEnd(14)} ${d}`) + + sys(lines.join('\n')) return true } @@ -1527,6 +1558,13 @@ export function App({ gw }: { gw: GatewayClient }) { return true + case 'provider': + gw.request('slash.exec', { command: 'provider', session_id: sid }) + .then((r: any) => page(r?.output || '(no output)')) + .catch(() => sys('provider command failed')) + + return true + case 'skin': if (arg) { rpc('config.set', { key: 'skin', value: arg }).then((r: any) => sys(`skin → ${r.value}`)) @@ -1714,6 +1752,56 @@ export function App({ gw }: { gw: GatewayClient }) { return true + case 'skills': { + const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean) + + if (!sub || sub === 'list') { + rpc('skills.manage', { action: 'list' }).then((r: any) => { + const sk = r.skills as Record | undefined + + if (!sk || !Object.keys(sk).length) return sys('no skills installed') + + const lines: string[] = [] + + for (const [cat, names] of Object.entries(sk)) { + lines.push(` ${cat}: ${(names as string[]).join(', ')}`) + } + + sys(lines.join('\n')) + }) + + return true + } + + if (sub === 'browse') { + const page = parseInt(sArgs[0] ?? '1', 10) || 1 + rpc('skills.manage', { action: 'browse', page }).then((r: any) => { + if (!r.items?.length) return sys('no skills found in the hub') + + const lines = [ + ` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, + '', + ...r.items.map((s: any) => + ` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}` + ), + ] + + if (r.page < r.total_pages) lines.push('', ` /skills browse ${r.page + 1} → next page`) + if (r.page > 1) lines.push(` /skills browse ${r.page - 1} → prev page`) + + sys(lines.join('\n')) + }) + + return true + } + + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => sys(r?.output || '/skills: no output')) + .catch(() => sys(`skills: ${sub} failed`)) + + return true + } + default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || `/${name}: no output`)) @@ -1737,7 +1825,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } }, - [catalog, compact, gw, lastUserMsg, messages, newSession, pastes, pushActivity, rpc, send, sid, statusBar, sys] + [catalog, compact, gw, lastUserMsg, messages, newSession, page, pastes, pushActivity, rpc, send, sid, statusBar, sys] ) slashRef.current = slash @@ -1974,6 +2062,20 @@ export function App({ gw }: { gw: GatewayClient }) { /> )} + {pager && ( + + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + {pager.offset + pagerPageSize < pager.lines.length + ? `── Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length}) ──` + : `── end · q to close (${pager.lines.length} lines) ──`} + + + )} + {!isBlocked && ( {inputBuf.map((line, i) => ( diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 1cfa035403..0c87b2cc76 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -77,6 +77,13 @@ export interface PendingPaste { export interface SlashCatalog { canon: Record + categories: SlashCategory[] pairs: [string, string][] + skillCount: number sub: Record } + +export interface SlashCategory { + name: string + pairs: [string, string][] +} From 17a9c47178e41f1e269c3e79273b5dd6170faab3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 9 Apr 2026 23:35:25 -0500 Subject: [PATCH 058/157] feat: support shift enter for ghostty etc --- ui-tui/src/entry.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 68dc9c0b72..3f719f4e1b 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -12,4 +12,8 @@ if (!process.stdin.isTTY) { const gw = new GatewayClient() gw.start() -render(, { exitOnCtrlC: false, maxFps: 60 }) +render(, { + exitOnCtrlC: false, + maxFps: 60, + kittyKeyboard: { mode: 'enabled', flags: ['disambiguateEscapeCodes'] }, +}) From 658cd2dd4ccb47c318897d90d7ddc535395f683d Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Fri, 10 Apr 2026 00:46:37 -0400 Subject: [PATCH 059/157] nix: add tui lockfile update script --- .envrc | 2 +- flake.lock | 21 ++ flake.nix | 13 +- nix/packages.nix | 16 +- nix/tui.nix | 27 +- ui-tui/package-lock.json | 766 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 818 insertions(+), 27 deletions(-) diff --git a/.envrc b/.envrc index a98c03b2c6..45c59523cb 100644 --- a/.envrc +++ b/.envrc @@ -1,5 +1,5 @@ watch_file pyproject.toml uv.lock watch_file ui-tui/package-lock.json ui-tui/package.json -watch_file nix/ +watch_file flake.nix flake.lock nix/devShell.nix nix/tui.nix nix/package.nix nix/python.nix use flake diff --git a/flake.lock b/flake.lock index 78ceba92d7..ad530ea1d3 100644 --- a/flake.lock +++ b/flake.lock @@ -36,6 +36,26 @@ "type": "github" } }, + "npm-lockfile-fix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1734767823, + "narHash": "sha256-UHVfdyuOdGEDRPkoSJRsX7HhN8oL/g903QUlzhBTadI=", + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "rev": "193e463bf27a36f85775eddde7189f93a493d2b3", + "type": "github" + }, + "original": { + "owner": "jeslie0", + "repo": "npm-lockfile-fix", + "type": "github" + } + }, "pyproject-build-systems": { "inputs": { "nixpkgs": [ @@ -124,6 +144,7 @@ "inputs": { "flake-parts": "flake-parts", "nixpkgs": "nixpkgs", + "npm-lockfile-fix": "npm-lockfile-fix", "pyproject-build-systems": "pyproject-build-systems", "pyproject-nix": "pyproject-nix_2", "uv2nix": "uv2nix_2" diff --git a/flake.nix b/flake.nix index 919fa434dc..fcb5eaa619 100644 --- a/flake.nix +++ b/flake.nix @@ -19,11 +19,20 @@ url = "github:pyproject-nix/build-system-pkgs"; inputs.nixpkgs.follows = "nixpkgs"; }; + npm-lockfile-fix = { + url = "github:jeslie0/npm-lockfile-fix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; }; - outputs = inputs: + outputs = + inputs: inputs.flake-parts.lib.mkFlake { inherit inputs; } { - systems = [ "x86_64-linux" "aarch64-linux" "aarch64-darwin" ]; + systems = [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ]; imports = [ ./nix/packages.nix diff --git a/nix/packages.nix b/nix/packages.nix index 8795e35c88..924ce2d176 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -2,13 +2,15 @@ { inputs, ... }: { perSystem = - { pkgs, ... }: + { pkgs, inputs', ... }: let hermesVenv = pkgs.callPackage ./python.nix { inherit (inputs) uv2nix pyproject-nix pyproject-build-systems; }; - hermesTui = pkgs.callPackage ./tui.nix { }; + hermesTui = pkgs.callPackage ./tui.nix { + npm-lockfile-fix = inputs'.npm-lockfile-fix.packages.default; + }; # Import bundled skills, excluding runtime caches bundledSkills = pkgs.lib.cleanSourceWith { @@ -57,11 +59,11 @@ ${pkgs.lib.concatMapStringsSep "\n" (name: '' - makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \ - --suffix PATH : "${runtimePath}" \ - --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \ - --set HERMES_TUI_DIR $out/ui-tui \ - --set HERMES_PYTHON ${hermesVenv}/bin/python3 + makeWrapper ${hermesVenv}/bin/${name} $out/bin/${name} \ + --suffix PATH : "${runtimePath}" \ + --set HERMES_BUNDLED_SKILLS $out/share/hermes-agent/skills \ + --set HERMES_TUI_DIR $out/ui-tui \ + --set HERMES_PYTHON ${hermesVenv}/bin/python3 '') [ "hermes" diff --git a/nix/tui.nix b/nix/tui.nix index 0e88ea08c0..76bab7f53b 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -1,10 +1,10 @@ # nix/tui.nix — Hermes TUI (Ink/React) compiled with tsc and bundled -{ pkgs, ... }: +{ pkgs, npm-lockfile-fix, ... }: let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-iz6TrWec4MpfDLZR48V6XHoKnZkEn9x2t97YOqWZt5k="; + hash = "sha256-tlQ43Dv5S2p5Aw6ChSTvPXcI5/kkXHoeZsTlSLm75eM="; }; packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json")); @@ -34,6 +34,29 @@ pkgs.buildNpmPackage { runHook postInstall ''; + nativeBuildInputs = [ + (pkgs.writeShellScriptBin "update_tui_lockfile" '' + set -euo pipefail + + # get root of repo + REPO_ROOT=$(git rev-parse --show-toplevel) + + # cd into ui-tui and reinstall + cd "$REPO_ROOT/ui-tui" + rm -rf node_modules/ + npm install --package-lock-only + ${pkgs.lib.getExe' npm-lockfile-fix "npm-lockfile-fix"} ./package-lock.json + + # compute the new hash + sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" "$REPO_ROOT/nix/tui.nix" + NIX_OUTPUT=$(nix build .#tui 2>&1 || true) + NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}') + echo got new hash $NEW_HASH + sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" "$REPO_ROOT/nix/tui.nix" + echo "Updated npm hash in $NIX_FILE to $NEW_HASH" + '') + ]; + passthru.devShellHook = '' STAMP=".nix-stamps/hermes-tui" STAMP_VALUE="${npmLockHash}" diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 81b44fc537..f8dd19a161 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -33,6 +33,8 @@ }, "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", @@ -44,6 +46,8 @@ }, "node_modules/@babel/code-frame": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", "dependencies": { @@ -57,6 +61,8 @@ }, "node_modules/@babel/compat-data": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "dev": true, "license": "MIT", "engines": { @@ -65,9 +71,10 @@ }, "node_modules/@babel/core": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -95,6 +102,8 @@ }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -103,6 +112,8 @@ }, "node_modules/@babel/generator": { "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", "dev": true, "license": "MIT", "dependencies": { @@ -118,6 +129,8 @@ }, "node_modules/@babel/helper-compilation-targets": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "dev": true, "license": "MIT", "dependencies": { @@ -133,6 +146,8 @@ }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -141,6 +156,8 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, "license": "MIT", "engines": { @@ -149,6 +166,8 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "dev": true, "license": "MIT", "dependencies": { @@ -161,6 +180,8 @@ }, "node_modules/@babel/helper-module-transforms": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "dev": true, "license": "MIT", "dependencies": { @@ -177,6 +198,8 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -185,6 +208,8 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, "license": "MIT", "engines": { @@ -193,6 +218,8 @@ }, "node_modules/@babel/helper-validator-option": { "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -201,6 +228,8 @@ }, "node_modules/@babel/helpers": { "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", "dev": true, "license": "MIT", "dependencies": { @@ -213,6 +242,8 @@ }, "node_modules/@babel/parser": { "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", "dev": true, "license": "MIT", "dependencies": { @@ -227,6 +258,8 @@ }, "node_modules/@babel/template": { "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "dev": true, "license": "MIT", "dependencies": { @@ -240,6 +273,8 @@ }, "node_modules/@babel/traverse": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "dev": true, "license": "MIT", "dependencies": { @@ -257,6 +292,8 @@ }, "node_modules/@babel/types": { "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "dev": true, "license": "MIT", "dependencies": { @@ -269,6 +306,8 @@ }, "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" ], @@ -284,6 +323,8 @@ }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -301,6 +342,8 @@ }, "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "license": "Apache-2.0", "engines": { @@ -312,6 +355,8 @@ }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -320,6 +365,8 @@ }, "node_modules/@eslint/config-array": { "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -333,6 +380,8 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -344,6 +393,8 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -353,11 +404,15 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -369,6 +424,8 @@ }, "node_modules/@eslint/core": { "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -380,6 +437,8 @@ }, "node_modules/@eslint/eslintrc": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", "dev": true, "license": "MIT", "dependencies": { @@ -402,6 +461,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true, "license": "MIT", "engines": { @@ -413,6 +474,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -421,6 +484,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -432,6 +497,8 @@ }, "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -441,11 +508,15 @@ }, "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/@eslint/js": { "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", "dev": true, "license": "MIT", "engines": { @@ -457,6 +528,8 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -465,6 +538,8 @@ }, "node_modules/@eslint/plugin-kit": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -477,6 +552,8 @@ }, "node_modules/@humanfs/core": { "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -485,6 +562,8 @@ }, "node_modules/@humanfs/node": { "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -497,6 +576,8 @@ }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -509,6 +590,8 @@ }, "node_modules/@humanwhocodes/retry": { "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -521,6 +604,8 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "dependencies": { @@ -530,6 +615,8 @@ }, "node_modules/@jridgewell/remapping": { "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "dev": true, "license": "MIT", "dependencies": { @@ -539,6 +626,8 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "engines": { @@ -547,11 +636,15 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "dependencies": { @@ -561,6 +654,8 @@ }, "node_modules/@oxc-project/types": { "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", "dev": true, "license": "MIT", "funding": { @@ -569,6 +664,8 @@ }, "node_modules/@rolldown/binding-linux-x64-gnu": { "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", "cpu": [ "x64" ], @@ -584,6 +681,8 @@ }, "node_modules/@rolldown/binding-linux-x64-musl": { "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", "cpu": [ "x64" ], @@ -599,16 +698,22 @@ }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", "dev": true, "license": "MIT" }, "node_modules/@standard-schema/spec": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true, "license": "MIT" }, "node_modules/@types/chai": { "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { @@ -618,42 +723,51 @@ }, "node_modules/@types/deep-eql": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", "dev": true, "license": "MIT" }, "node_modules/@types/estree": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, "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", - "peer": true, "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==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", @@ -679,9 +793,10 @@ }, "node_modules/@typescript-eslint/parser": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", @@ -703,6 +818,8 @@ }, "node_modules/@typescript-eslint/project-service": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", "dev": true, "license": "MIT", "dependencies": { @@ -723,6 +840,8 @@ }, "node_modules/@typescript-eslint/scope-manager": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", "dev": true, "license": "MIT", "dependencies": { @@ -739,6 +858,8 @@ }, "node_modules/@typescript-eslint/tsconfig-utils": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", "dev": true, "license": "MIT", "engines": { @@ -754,6 +875,8 @@ }, "node_modules/@typescript-eslint/type-utils": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", "dev": true, "license": "MIT", "dependencies": { @@ -777,6 +900,8 @@ }, "node_modules/@typescript-eslint/types": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", "dev": true, "license": "MIT", "engines": { @@ -789,6 +914,8 @@ }, "node_modules/@typescript-eslint/typescript-estree": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", "dev": true, "license": "MIT", "dependencies": { @@ -815,6 +942,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -829,6 +958,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -840,6 +971,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", "dev": true, "license": "MIT", "engines": { @@ -848,6 +981,8 @@ }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -859,6 +994,8 @@ }, "node_modules/@typescript-eslint/utils": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", "dev": true, "license": "MIT", "dependencies": { @@ -881,6 +1018,8 @@ }, "node_modules/@typescript-eslint/visitor-keys": { "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -897,6 +1036,8 @@ }, "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -908,6 +1049,8 @@ }, "node_modules/@vitest/expect": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", + "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -924,6 +1067,8 @@ }, "node_modules/@vitest/mocker": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", + "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", "dev": true, "license": "MIT", "dependencies": { @@ -949,6 +1094,8 @@ }, "node_modules/@vitest/pretty-format": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", + "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", "dev": true, "license": "MIT", "dependencies": { @@ -960,6 +1107,8 @@ }, "node_modules/@vitest/runner": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", + "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", "dev": true, "license": "MIT", "dependencies": { @@ -972,6 +1121,8 @@ }, "node_modules/@vitest/snapshot": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", + "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -986,6 +1137,8 @@ }, "node_modules/@vitest/spy": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", + "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", "dev": true, "license": "MIT", "funding": { @@ -994,6 +1147,8 @@ }, "node_modules/@vitest/utils": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", + "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", "dev": true, "license": "MIT", "dependencies": { @@ -1007,9 +1162,10 @@ }, "node_modules/acorn": { "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1019,6 +1175,8 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1027,6 +1185,8 @@ }, "node_modules/ajv": { "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1042,6 +1202,8 @@ }, "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" @@ -1055,6 +1217,8 @@ }, "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" @@ -1065,6 +1229,8 @@ }, "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" @@ -1075,11 +1241,15 @@ }, "node_modules/argparse": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", "dev": true, "license": "MIT", "dependencies": { @@ -1095,6 +1265,8 @@ }, "node_modules/array-includes": { "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1116,6 +1288,8 @@ }, "node_modules/array.prototype.findlast": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1135,6 +1309,8 @@ }, "node_modules/array.prototype.flat": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", "dev": true, "license": "MIT", "dependencies": { @@ -1152,6 +1328,8 @@ }, "node_modules/array.prototype.flatmap": { "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", "dev": true, "license": "MIT", "dependencies": { @@ -1169,6 +1347,8 @@ }, "node_modules/array.prototype.tosorted": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "license": "MIT", "dependencies": { @@ -1184,6 +1364,8 @@ }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1204,6 +1386,8 @@ }, "node_modules/assertion-error": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", "dev": true, "license": "MIT", "engines": { @@ -1212,6 +1396,8 @@ }, "node_modules/async-function": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", "dev": true, "license": "MIT", "engines": { @@ -1220,6 +1406,8 @@ }, "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" @@ -1230,6 +1418,8 @@ }, "node_modules/available-typed-arrays": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1244,6 +1434,8 @@ }, "node_modules/baseline-browser-mapping": { "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1255,6 +1447,8 @@ }, "node_modules/browserslist": { "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1271,7 +1465,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -1288,6 +1481,8 @@ }, "node_modules/call-bind": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", "dev": true, "license": "MIT", "dependencies": { @@ -1305,6 +1500,8 @@ }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1317,6 +1514,8 @@ }, "node_modules/call-bound": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", "dev": true, "license": "MIT", "dependencies": { @@ -1332,6 +1531,8 @@ }, "node_modules/callsites": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, "license": "MIT", "engines": { @@ -1340,6 +1541,8 @@ }, "node_modules/caniuse-lite": { "version": "1.0.30001784", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", + "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", "dev": true, "funding": [ { @@ -1359,6 +1562,8 @@ }, "node_modules/chai": { "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true, "license": "MIT", "engines": { @@ -1367,6 +1572,8 @@ }, "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" @@ -1377,6 +1584,8 @@ }, "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" @@ -1390,6 +1599,8 @@ }, "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", @@ -1404,6 +1615,8 @@ }, "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", @@ -1418,6 +1631,8 @@ }, "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" @@ -1428,6 +1643,8 @@ }, "node_modules/color-convert": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1439,21 +1656,29 @@ }, "node_modules/color-name": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, "license": "MIT" }, "node_modules/concat-map": { "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true, "license": "MIT" }, "node_modules/convert-source-map": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, "license": "MIT" }, "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" @@ -1461,6 +1686,8 @@ }, "node_modules/cross-spawn": { "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { @@ -1474,11 +1701,15 @@ }, "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==", "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1495,6 +1726,8 @@ }, "node_modules/data-view-byte-length": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1511,6 +1744,8 @@ }, "node_modules/data-view-byte-offset": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1527,6 +1762,8 @@ }, "node_modules/debug": { "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -1543,11 +1780,15 @@ }, "node_modules/deep-is": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true, "license": "MIT" }, "node_modules/define-data-property": { "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1564,6 +1805,8 @@ }, "node_modules/define-properties": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, "license": "MIT", "dependencies": { @@ -1580,6 +1823,8 @@ }, "node_modules/detect-libc": { "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1588,6 +1833,8 @@ }, "node_modules/doctrine": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1599,6 +1846,8 @@ }, "node_modules/dunder-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", "dev": true, "license": "MIT", "dependencies": { @@ -1612,15 +1861,21 @@ }, "node_modules/electron-to-chromium": { "version": "1.5.331", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", + "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", "dev": true, "license": "ISC" }, "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" @@ -1631,6 +1886,8 @@ }, "node_modules/es-abstract": { "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "dev": true, "license": "MIT", "dependencies": { @@ -1698,6 +1955,8 @@ }, "node_modules/es-define-property": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", "engines": { @@ -1706,6 +1965,8 @@ }, "node_modules/es-errors": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", "dev": true, "license": "MIT", "engines": { @@ -1714,6 +1975,8 @@ }, "node_modules/es-iterator-helpers": { "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1741,11 +2004,15 @@ }, "node_modules/es-module-lexer": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, "node_modules/es-object-atoms": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -1757,6 +2024,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "dev": true, "license": "MIT", "dependencies": { @@ -1771,6 +2040,8 @@ }, "node_modules/es-shim-unscopables": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", "dev": true, "license": "MIT", "dependencies": { @@ -1782,6 +2053,8 @@ }, "node_modules/es-to-primitive": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "dev": true, "license": "MIT", "dependencies": { @@ -1798,6 +2071,8 @@ }, "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", @@ -1806,6 +2081,8 @@ }, "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", @@ -1846,6 +2123,8 @@ }, "node_modules/escalade": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -1854,9 +2133,10 @@ }, "node_modules/eslint": { "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1913,6 +2193,8 @@ }, "node_modules/eslint-plugin-perfectionist": { "version": "5.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.8.0.tgz", + "integrity": "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw==", "dev": true, "license": "MIT", "dependencies": { @@ -1928,6 +2210,8 @@ }, "node_modules/eslint-plugin-react": { "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", "dependencies": { @@ -1959,6 +2243,8 @@ }, "node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", "dependencies": { @@ -1977,6 +2263,8 @@ }, "node_modules/eslint-plugin-react/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1988,6 +2276,8 @@ }, "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -1997,11 +2287,15 @@ }, "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -2010,6 +2304,8 @@ }, "node_modules/eslint-plugin-unused-imports": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.4.1.tgz", + "integrity": "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2024,6 +2320,8 @@ }, "node_modules/eslint-scope": { "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2039,6 +2337,8 @@ }, "node_modules/eslint/node_modules/ansi-styles": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", "dependencies": { @@ -2053,11 +2353,15 @@ }, "node_modules/eslint/node_modules/balanced-match": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true, "license": "MIT" }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2067,6 +2371,8 @@ }, "node_modules/eslint/node_modules/chalk": { "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", "dependencies": { @@ -2082,6 +2388,8 @@ }, "node_modules/eslint/node_modules/escape-string-regexp": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", "dev": true, "license": "MIT", "engines": { @@ -2093,6 +2401,8 @@ }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2104,6 +2414,8 @@ }, "node_modules/eslint/node_modules/ignore": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -2112,6 +2424,8 @@ }, "node_modules/eslint/node_modules/minimatch": { "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -2123,6 +2437,8 @@ }, "node_modules/espree": { "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2139,6 +2455,8 @@ }, "node_modules/espree/node_modules/eslint-visitor-keys": { "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2150,6 +2468,8 @@ }, "node_modules/esquery": { "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2161,6 +2481,8 @@ }, "node_modules/esrecurse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2172,6 +2494,8 @@ }, "node_modules/estraverse": { "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2180,6 +2504,8 @@ }, "node_modules/estree-walker": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", "dependencies": { @@ -2188,6 +2514,8 @@ }, "node_modules/esutils": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -2196,6 +2524,8 @@ }, "node_modules/expect-type": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2204,21 +2534,29 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true, "license": "MIT" }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true, "license": "MIT" }, "node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", "engines": { @@ -2235,6 +2573,8 @@ }, "node_modules/file-entry-cache": { "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2246,6 +2586,8 @@ }, "node_modules/find-up": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, "license": "MIT", "dependencies": { @@ -2261,6 +2603,8 @@ }, "node_modules/flat-cache": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", "dev": true, "license": "MIT", "dependencies": { @@ -2273,11 +2617,15 @@ }, "node_modules/flatted": { "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2292,6 +2640,8 @@ }, "node_modules/function-bind": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", "dev": true, "license": "MIT", "funding": { @@ -2300,6 +2650,8 @@ }, "node_modules/function.prototype.name": { "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "dev": true, "license": "MIT", "dependencies": { @@ -2319,6 +2671,8 @@ }, "node_modules/functions-have-names": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", "dev": true, "license": "MIT", "funding": { @@ -2327,6 +2681,8 @@ }, "node_modules/generator-function": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", "dev": true, "license": "MIT", "engines": { @@ -2335,6 +2691,8 @@ }, "node_modules/gensync": { "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "dev": true, "license": "MIT", "engines": { @@ -2343,6 +2701,8 @@ }, "node_modules/get-east-asian-width": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", "license": "MIT", "engines": { "node": ">=18" @@ -2353,6 +2713,8 @@ }, "node_modules/get-intrinsic": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2376,6 +2738,8 @@ }, "node_modules/get-proto": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", "dev": true, "license": "MIT", "dependencies": { @@ -2388,6 +2752,8 @@ }, "node_modules/get-symbol-description": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", "dev": true, "license": "MIT", "dependencies": { @@ -2404,6 +2770,8 @@ }, "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": { @@ -2415,6 +2783,8 @@ }, "node_modules/glob-parent": { "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", "dependencies": { @@ -2426,6 +2796,8 @@ }, "node_modules/globals": { "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", "dev": true, "license": "MIT", "engines": { @@ -2437,6 +2809,8 @@ }, "node_modules/globalthis": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2452,6 +2826,8 @@ }, "node_modules/gopd": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", "engines": { @@ -2463,6 +2839,8 @@ }, "node_modules/has-bigints": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", "dev": true, "license": "MIT", "engines": { @@ -2474,6 +2852,8 @@ }, "node_modules/has-flag": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, "license": "MIT", "engines": { @@ -2482,6 +2862,8 @@ }, "node_modules/has-property-descriptors": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", "dev": true, "license": "MIT", "dependencies": { @@ -2493,6 +2875,8 @@ }, "node_modules/has-proto": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2507,6 +2891,8 @@ }, "node_modules/has-symbols": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -2518,6 +2904,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "license": "MIT", "dependencies": { @@ -2532,6 +2920,8 @@ }, "node_modules/hasown": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2543,11 +2933,15 @@ }, "node_modules/hermes-estree": { "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", "dev": true, "license": "MIT" }, "node_modules/hermes-parser": { "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", "dev": true, "license": "MIT", "dependencies": { @@ -2556,6 +2950,8 @@ }, "node_modules/ignore": { "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -2564,6 +2960,8 @@ }, "node_modules/import-fresh": { "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2579,6 +2977,8 @@ }, "node_modules/imurmurhash": { "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, "license": "MIT", "engines": { @@ -2587,6 +2987,8 @@ }, "node_modules/indent-string": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", "license": "MIT", "engines": { "node": ">=12" @@ -2597,6 +2999,8 @@ }, "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", @@ -2644,6 +3048,8 @@ }, "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", @@ -2659,6 +3065,8 @@ }, "node_modules/ink-text-input/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" @@ -2669,6 +3077,8 @@ }, "node_modules/ink-text-input/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" @@ -2679,6 +3089,8 @@ }, "node_modules/ink/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" @@ -2689,6 +3101,8 @@ }, "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", @@ -2703,6 +3117,8 @@ }, "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" @@ -2716,6 +3132,8 @@ }, "node_modules/internal-slot": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { @@ -2729,6 +3147,8 @@ }, "node_modules/is-array-buffer": { "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", "dev": true, "license": "MIT", "dependencies": { @@ -2745,6 +3165,8 @@ }, "node_modules/is-async-function": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2763,6 +3185,8 @@ }, "node_modules/is-bigint": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2777,6 +3201,8 @@ }, "node_modules/is-boolean-object": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", "dev": true, "license": "MIT", "dependencies": { @@ -2792,6 +3218,8 @@ }, "node_modules/is-callable": { "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", "dev": true, "license": "MIT", "engines": { @@ -2803,6 +3231,8 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "license": "MIT", "dependencies": { @@ -2817,6 +3247,8 @@ }, "node_modules/is-data-view": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", "dev": true, "license": "MIT", "dependencies": { @@ -2833,6 +3265,8 @@ }, "node_modules/is-date-object": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", "dev": true, "license": "MIT", "dependencies": { @@ -2848,6 +3282,8 @@ }, "node_modules/is-extglob": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, "license": "MIT", "engines": { @@ -2856,6 +3292,8 @@ }, "node_modules/is-finalizationregistry": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", "dev": true, "license": "MIT", "dependencies": { @@ -2870,6 +3308,8 @@ }, "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" @@ -2883,6 +3323,8 @@ }, "node_modules/is-generator-function": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", "dev": true, "license": "MIT", "dependencies": { @@ -2901,6 +3343,8 @@ }, "node_modules/is-glob": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "dev": true, "license": "MIT", "dependencies": { @@ -2912,6 +3356,8 @@ }, "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" @@ -2925,6 +3371,8 @@ }, "node_modules/is-map": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, "license": "MIT", "engines": { @@ -2936,6 +3384,8 @@ }, "node_modules/is-negative-zero": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "license": "MIT", "engines": { @@ -2947,6 +3397,8 @@ }, "node_modules/is-number-object": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", "dev": true, "license": "MIT", "dependencies": { @@ -2962,6 +3414,8 @@ }, "node_modules/is-regex": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", "dev": true, "license": "MIT", "dependencies": { @@ -2979,6 +3433,8 @@ }, "node_modules/is-set": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, "license": "MIT", "engines": { @@ -2990,6 +3446,8 @@ }, "node_modules/is-shared-array-buffer": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", "dev": true, "license": "MIT", "dependencies": { @@ -3004,6 +3462,8 @@ }, "node_modules/is-string": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", "dev": true, "license": "MIT", "dependencies": { @@ -3019,6 +3479,8 @@ }, "node_modules/is-symbol": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", "dev": true, "license": "MIT", "dependencies": { @@ -3035,6 +3497,8 @@ }, "node_modules/is-typed-array": { "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3049,6 +3513,8 @@ }, "node_modules/is-weakmap": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, "license": "MIT", "engines": { @@ -3060,6 +3526,8 @@ }, "node_modules/is-weakref": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "dev": true, "license": "MIT", "dependencies": { @@ -3074,6 +3542,8 @@ }, "node_modules/is-weakset": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3089,16 +3559,22 @@ }, "node_modules/isarray": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true, "license": "ISC" }, "node_modules/iterator.prototype": { "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", "dev": true, "license": "MIT", "dependencies": { @@ -3115,11 +3591,15 @@ }, "node_modules/js-tokens": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "dev": true, "license": "MIT" }, "node_modules/js-yaml": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { @@ -3131,6 +3611,8 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -3142,21 +3624,29 @@ }, "node_modules/json-buffer": { "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true, "license": "MIT", "bin": { @@ -3168,6 +3658,8 @@ }, "node_modules/jsx-ast-utils": { "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3182,6 +3674,8 @@ }, "node_modules/keyv": { "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", "dev": true, "license": "MIT", "dependencies": { @@ -3190,6 +3684,8 @@ }, "node_modules/levn": { "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3202,6 +3698,8 @@ }, "node_modules/lightningcss": { "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -3230,6 +3728,8 @@ }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], @@ -3249,6 +3749,8 @@ }, "node_modules/lightningcss-linux-x64-musl": { "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], @@ -3268,6 +3770,8 @@ }, "node_modules/locate-path": { "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { @@ -3282,11 +3786,15 @@ }, "node_modules/lodash.merge": { "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true, "license": "MIT" }, "node_modules/loose-envify": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3298,6 +3806,8 @@ }, "node_modules/lru-cache": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -3306,6 +3816,8 @@ }, "node_modules/magic-string": { "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3314,6 +3826,8 @@ }, "node_modules/math-intrinsics": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", "dev": true, "license": "MIT", "engines": { @@ -3322,6 +3836,8 @@ }, "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" @@ -3329,11 +3845,15 @@ }, "node_modules/ms": { "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "dev": true, "license": "MIT" }, "node_modules/nanoid": { "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -3351,11 +3871,15 @@ }, "node_modules/natural-compare": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true, "license": "MIT" }, "node_modules/natural-orderby": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", "dev": true, "license": "MIT", "engines": { @@ -3364,6 +3888,8 @@ }, "node_modules/node-exports-info": { "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3381,6 +3907,8 @@ }, "node_modules/node-exports-info/node_modules/semver": { "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", "bin": { @@ -3389,11 +3917,15 @@ }, "node_modules/node-releases": { "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", "dev": true, "license": "MIT" }, "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "dev": true, "license": "MIT", "engines": { @@ -3402,6 +3934,8 @@ }, "node_modules/object-inspect": { "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -3413,6 +3947,8 @@ }, "node_modules/object-keys": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "dev": true, "license": "MIT", "engines": { @@ -3421,6 +3957,8 @@ }, "node_modules/object.assign": { "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", "dev": true, "license": "MIT", "dependencies": { @@ -3440,6 +3978,8 @@ }, "node_modules/object.entries": { "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", "dev": true, "license": "MIT", "dependencies": { @@ -3454,6 +3994,8 @@ }, "node_modules/object.fromentries": { "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3471,6 +4013,8 @@ }, "node_modules/object.values": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", "dev": true, "license": "MIT", "dependencies": { @@ -3488,6 +4032,8 @@ }, "node_modules/obug": { "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true, "funding": [ "https://github.com/sponsors/sxzz", @@ -3497,6 +4043,8 @@ }, "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" @@ -3510,6 +4058,8 @@ }, "node_modules/optionator": { "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dev": true, "license": "MIT", "dependencies": { @@ -3526,6 +4076,8 @@ }, "node_modules/own-keys": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", "dev": true, "license": "MIT", "dependencies": { @@ -3542,6 +4094,8 @@ }, "node_modules/p-limit": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3556,6 +4110,8 @@ }, "node_modules/p-locate": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, "license": "MIT", "dependencies": { @@ -3570,6 +4126,8 @@ }, "node_modules/parent-module": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", "dev": true, "license": "MIT", "dependencies": { @@ -3581,6 +4139,8 @@ }, "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" @@ -3588,6 +4148,8 @@ }, "node_modules/path-exists": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "dev": true, "license": "MIT", "engines": { @@ -3596,6 +4158,8 @@ }, "node_modules/path-key": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, "license": "MIT", "engines": { @@ -3604,24 +4168,31 @@ }, "node_modules/path-parse": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true, "license": "MIT" }, "node_modules/pathe": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true, "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3631,6 +4202,8 @@ }, "node_modules/possible-typed-array-names": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", "dev": true, "license": "MIT", "engines": { @@ -3639,6 +4212,8 @@ }, "node_modules/postcss": { "version": "8.5.9", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", + "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", "dev": true, "funding": [ { @@ -3666,6 +4241,8 @@ }, "node_modules/prelude-ls": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", "dev": true, "license": "MIT", "engines": { @@ -3674,6 +4251,8 @@ }, "node_modules/prettier": { "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", "bin": { @@ -3688,6 +4267,8 @@ }, "node_modules/prop-types": { "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "dev": true, "license": "MIT", "dependencies": { @@ -3698,6 +4279,8 @@ }, "node_modules/punycode": { "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", "dev": true, "license": "MIT", "engines": { @@ -3706,19 +4289,24 @@ }, "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", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-is": { "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true, "license": "MIT" }, "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" @@ -3732,6 +4320,8 @@ }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", "dev": true, "license": "MIT", "dependencies": { @@ -3753,6 +4343,8 @@ }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", "dev": true, "license": "MIT", "dependencies": { @@ -3772,6 +4364,8 @@ }, "node_modules/resolve": { "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", "dev": true, "license": "MIT", "dependencies": { @@ -3794,6 +4388,8 @@ }, "node_modules/resolve-from": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true, "license": "MIT", "engines": { @@ -3802,6 +4398,8 @@ }, "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": { @@ -3810,6 +4408,8 @@ }, "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", @@ -3824,6 +4424,8 @@ }, "node_modules/rolldown": { "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", "dev": true, "license": "MIT", "dependencies": { @@ -3856,6 +4458,8 @@ }, "node_modules/safe-array-concat": { "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -3874,6 +4478,8 @@ }, "node_modules/safe-push-apply": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", "dev": true, "license": "MIT", "dependencies": { @@ -3889,6 +4495,8 @@ }, "node_modules/safe-regex-test": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", "dev": true, "license": "MIT", "dependencies": { @@ -3905,10 +4513,14 @@ }, "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/set-function-length": { "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dev": true, "license": "MIT", "dependencies": { @@ -3925,6 +4537,8 @@ }, "node_modules/set-function-name": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3939,6 +4553,8 @@ }, "node_modules/set-proto": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", "dev": true, "license": "MIT", "dependencies": { @@ -3952,6 +4568,8 @@ }, "node_modules/shebang-command": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, "license": "MIT", "dependencies": { @@ -3963,6 +4581,8 @@ }, "node_modules/shebang-regex": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, "license": "MIT", "engines": { @@ -3971,6 +4591,8 @@ }, "node_modules/side-channel": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { @@ -3989,6 +4611,8 @@ }, "node_modules/side-channel-list": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", "dev": true, "license": "MIT", "dependencies": { @@ -4004,6 +4628,8 @@ }, "node_modules/side-channel-map": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", "dev": true, "license": "MIT", "dependencies": { @@ -4021,6 +4647,8 @@ }, "node_modules/side-channel-weakmap": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "dev": true, "license": "MIT", "dependencies": { @@ -4039,15 +4667,21 @@ }, "node_modules/siginfo": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, "license": "ISC" }, "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", @@ -4062,6 +4696,8 @@ }, "node_modules/source-map-js": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4070,6 +4706,8 @@ }, "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" @@ -4080,6 +4718,8 @@ }, "node_modules/stack-utils/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" @@ -4087,16 +4727,22 @@ }, "node_modules/stackback": { "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", "dev": true, "license": "MIT" }, "node_modules/std-env": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", "dev": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4109,6 +4755,8 @@ }, "node_modules/string.prototype.matchall": { "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", "dev": true, "license": "MIT", "dependencies": { @@ -4135,6 +4783,8 @@ }, "node_modules/string.prototype.repeat": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "license": "MIT", "dependencies": { @@ -4144,6 +4794,8 @@ }, "node_modules/string.prototype.trim": { "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", "dev": true, "license": "MIT", "dependencies": { @@ -4164,6 +4816,8 @@ }, "node_modules/string.prototype.trimend": { "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4181,6 +4835,8 @@ }, "node_modules/string.prototype.trimstart": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "license": "MIT", "dependencies": { @@ -4197,6 +4853,8 @@ }, "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" @@ -4210,6 +4868,8 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, "license": "MIT", "engines": { @@ -4221,6 +4881,8 @@ }, "node_modules/supports-color": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", "dependencies": { @@ -4232,6 +4894,8 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "dev": true, "license": "MIT", "engines": { @@ -4243,6 +4907,8 @@ }, "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" @@ -4253,6 +4919,8 @@ }, "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" @@ -4263,11 +4931,15 @@ }, "node_modules/tinybench": { "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true, "license": "MIT" }, "node_modules/tinyexec": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -4276,6 +4948,8 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4291,6 +4965,8 @@ }, "node_modules/tinyrainbow": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -4299,6 +4975,8 @@ }, "node_modules/ts-api-utils": { "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", "dev": true, "license": "MIT", "engines": { @@ -4310,9 +4988,10 @@ }, "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", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -4329,6 +5008,8 @@ }, "node_modules/type-check": { "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", "dev": true, "license": "MIT", "dependencies": { @@ -4340,6 +5021,8 @@ }, "node_modules/typed-array-buffer": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dev": true, "license": "MIT", "dependencies": { @@ -4353,6 +5036,8 @@ }, "node_modules/typed-array-byte-length": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", "dev": true, "license": "MIT", "dependencies": { @@ -4371,6 +5056,8 @@ }, "node_modules/typed-array-byte-offset": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4391,6 +5078,8 @@ }, "node_modules/typed-array-length": { "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", "dev": true, "license": "MIT", "dependencies": { @@ -4410,9 +5099,10 @@ }, "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", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4423,6 +5113,8 @@ }, "node_modules/unbox-primitive": { "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", "dev": true, "license": "MIT", "dependencies": { @@ -4440,11 +5132,15 @@ }, "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/unicode-animations": { "version": "1.0.3", + "resolved": "https://registry.npmjs.org/unicode-animations/-/unicode-animations-1.0.3.tgz", + "integrity": "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -4456,6 +5152,8 @@ }, "node_modules/update-browserslist-db": { "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -4485,6 +5183,8 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -4493,9 +5193,10 @@ }, "node_modules/vite": { "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4570,6 +5271,8 @@ }, "node_modules/vitest": { "version": "4.1.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", + "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", "dev": true, "license": "MIT", "dependencies": { @@ -4658,6 +5361,8 @@ }, "node_modules/which": { "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, "license": "ISC", "dependencies": { @@ -4672,6 +5377,8 @@ }, "node_modules/which-boxed-primitive": { "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", "dev": true, "license": "MIT", "dependencies": { @@ -4690,6 +5397,8 @@ }, "node_modules/which-builtin-type": { "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", "dev": true, "license": "MIT", "dependencies": { @@ -4716,6 +5425,8 @@ }, "node_modules/which-collection": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "license": "MIT", "dependencies": { @@ -4733,6 +5444,8 @@ }, "node_modules/which-typed-array": { "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -4753,6 +5466,8 @@ }, "node_modules/why-is-node-running": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", "dev": true, "license": "MIT", "dependencies": { @@ -4768,6 +5483,8 @@ }, "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" @@ -4781,6 +5498,8 @@ }, "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", @@ -4795,6 +5514,8 @@ }, "node_modules/word-wrap": { "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", "dev": true, "license": "MIT", "engines": { @@ -4803,6 +5524,8 @@ }, "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", @@ -4818,6 +5541,8 @@ }, "node_modules/wrap-ansi/node_modules/string-width": { "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -4833,6 +5558,8 @@ }, "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" @@ -4852,11 +5579,15 @@ }, "node_modules/yallist": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, "node_modules/yocto-queue": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, "license": "MIT", "engines": { @@ -4868,19 +5599,24 @@ }, "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" }, "node_modules/zod": { "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "node_modules/zod-validation-error": { "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", "dev": true, "license": "MIT", "engines": { @@ -4891,4 +5627,4 @@ } } } -} +} \ No newline at end of file From bc80848e4937dbc81293638210c9a5601294c9ff Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Fri, 10 Apr 2026 00:50:39 -0400 Subject: [PATCH 060/157] update lockfile --- nix/tui.nix | 2 +- ui-tui/package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nix/tui.nix b/nix/tui.nix index 76bab7f53b..a3a8e1a640 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -44,7 +44,7 @@ pkgs.buildNpmPackage { # cd into ui-tui and reinstall cd "$REPO_ROOT/ui-tui" rm -rf node_modules/ - npm install --package-lock-only + npm install ${pkgs.lib.getExe' npm-lockfile-fix "npm-lockfile-fix"} ./package-lock.json # compute the new hash diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index f8dd19a161..4d593f4ab8 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -5627,4 +5627,4 @@ } } } -} \ No newline at end of file +} From 660379637ab5e75b92f985949e460266254a697a Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Fri, 10 Apr 2026 01:41:29 -0400 Subject: [PATCH 061/157] one more nix fix --- nix/tui.nix | 9 +- ui-tui/package-lock.json | 1803 +++++++++++++++++++++++++++++--------- 2 files changed, 1371 insertions(+), 441 deletions(-) diff --git a/nix/tui.nix b/nix/tui.nix index a3a8e1a640..5b0ea32682 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-tlQ43Dv5S2p5Aw6ChSTvPXcI5/kkXHoeZsTlSLm75eM="; + hash = "sha256-zFGNrlB07I5MwF+Fo4Jf/MZnKIFzkfD+MoL+svt6Fr0="; }; packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json")); @@ -44,15 +44,18 @@ pkgs.buildNpmPackage { # cd into ui-tui and reinstall cd "$REPO_ROOT/ui-tui" rm -rf node_modules/ + npm cache clean --force npm install ${pkgs.lib.getExe' npm-lockfile-fix "npm-lockfile-fix"} ./package-lock.json + NIX_FILE="$REPO_ROOT/nix/tui.nix" # compute the new hash - sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" "$REPO_ROOT/nix/tui.nix" + sed -i "s/hash = \"[^\"]*\";/hash = \"\";/" $NIX_FILE NIX_OUTPUT=$(nix build .#tui 2>&1 || true) NEW_HASH=$(echo "$NIX_OUTPUT" | grep 'got:' | awk '{print $2}') echo got new hash $NEW_HASH - sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" "$REPO_ROOT/nix/tui.nix" + sed -i "s|hash = \"[^\"]*\";|hash = \"$NEW_HASH\";|" $NIX_FILE + nix build .#tui echo "Updated npm hash in $NIX_FILE to $NEW_HASH" '') ]; diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 4d593f4ab8..1a0cb48596 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -44,6 +44,18 @@ "node": ">=18" } }, + "node_modules/@alcalzone/ansi-tokenize/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/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -304,10 +316,316 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "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==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", "cpu": [ "x64" ], @@ -321,6 +639,159 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -340,19 +811,6 @@ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/@eslint-community/regexpp": { "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", @@ -378,6 +836,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -391,24 +867,6 @@ "node": "*" } }, - "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/config-array/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/config-helpers": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", @@ -459,6 +917,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/@eslint/eslintrc/node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -495,24 +971,6 @@ "node": "*" } }, - "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/@eslint/eslintrc/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/@eslint/js": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", @@ -652,20 +1110,192 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", + "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oxc-project/types": { - "version": "0.123.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", - "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", - "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", "cpu": [ "x64" ], @@ -680,9 +1310,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", - "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", "cpu": [ "x64" ], @@ -696,10 +1326,80 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", - "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", "dev": true, "license": "MIT" }, @@ -710,6 +1410,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -743,13 +1454,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/react": { @@ -763,17 +1474,17 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", - "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/type-utils": "8.58.0", - "@typescript-eslint/utils": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -786,22 +1497,22 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.58.0", + "@typescript-eslint/parser": "^8.58.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", - "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3" }, "engines": { @@ -817,14 +1528,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", - "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.58.0", - "@typescript-eslint/types": "^8.58.0", + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", "debug": "^4.4.3" }, "engines": { @@ -839,14 +1550,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", - "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0" + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -857,9 +1568,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", - "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", "dev": true, "license": "MIT", "engines": { @@ -874,15 +1585,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", - "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0", - "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -899,9 +1610,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", - "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", "dev": true, "license": "MIT", "engines": { @@ -913,16 +1624,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", - "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.58.0", - "@typescript-eslint/tsconfig-utils": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/visitor-keys": "8.58.0", + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -940,69 +1651,17 @@ "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", - "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.58.0", - "@typescript-eslint/types": "8.58.0", - "@typescript-eslint/typescript-estree": "8.58.0" + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1017,13 +1676,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.58.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", - "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/types": "8.58.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -1048,16 +1707,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.3.tgz", - "integrity": "sha512-CW8Q9KMtXDGHj0vCsqui0M5KqRsu0zm0GNDW7Gd3U7nZ2RFpPKSCpeCXoT+/+5zr1TNlsoQRDEz+LzZUyq6gnQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.4.tgz", + "integrity": "sha512-iPBpra+VDuXmBFI3FMKHSFXp3Gx5HfmSCE8X67Dn+bwephCnQCaB7qWK2ldHa+8ncN8hJU8VTMcxjPpyMkUjww==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1066,13 +1725,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.3.tgz", - "integrity": "sha512-XN3TrycitDQSzGRnec/YWgoofkYRhouyVQj4YNsJ5r/STCUFqMrP4+oxEv3e7ZbLi4og5kIHrZwekDJgw6hcjw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.4.tgz", + "integrity": "sha512-R9HTZBhW6yCSGbGQnDnH3QHfJxokKN4KB+Yvk9Q1le7eQNYwiCyKxmLmurSpFy6BzJanSLuEUDrD+j97Q+ZLPg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.3", + "@vitest/spy": "4.1.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1093,9 +1752,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.3.tgz", - "integrity": "sha512-hYqqwuMbpkkBodpRh4k4cQSOELxXky1NfMmQvOfKvV8zQHz8x8Dla+2wzElkMkBvSAJX5TRGHJAQvK0TcOafwg==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.4.tgz", + "integrity": "sha512-ddmDHU0gjEUyEVLxtZa7xamrpIefdEETu3nZjWtHeZX4QxqJ7tRxSteHVXJOcr8jhiLoGAhkK4WJ3WqBpjx42A==", "dev": true, "license": "MIT", "dependencies": { @@ -1106,13 +1765,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.3.tgz", - "integrity": "sha512-VwgOz5MmT0KhlUj40h02LWDpUBVpflZ/b7xZFA25F29AJzIrE+SMuwzFf0b7t4EXdwRNX61C3B6auIXQTR3ttA==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.4.tgz", + "integrity": "sha512-xTp7VZ5aXP5ZJrn15UtJUWlx6qXLnGtF6jNxHepdPHpMfz/aVPx+htHtgcAL2mDXJgKhpoo2e9/hVJsIeFbytQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.3", + "@vitest/utils": "4.1.4", "pathe": "^2.0.3" }, "funding": { @@ -1120,14 +1779,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.3.tgz", - "integrity": "sha512-9l+k/J9KG5wPJDX9BcFFzhhwNjwkRb8RsnYhaT1vPY7OufxmQFc9sZzScRCPTiETzl37mrIWVY9zxzmdVeJwDQ==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.4.tgz", + "integrity": "sha512-MCjCFgaS8aZz+m5nTcEcgk/xhWv0rEH4Yl53PPlMXOZ1/Ka2VcZU6CJ+MgYCZbcJvzGhQRjVrGQNZqkGPttIKw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/pretty-format": "4.1.4", + "@vitest/utils": "4.1.4", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1136,9 +1795,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.3.tgz", - "integrity": "sha512-ujj5Uwxagg4XUIfAUyRQxAg631BP6e9joRiN99mr48Bg9fRs+5mdUElhOoZ6rP5mBr8Bs3lmrREnkrQWkrsTCw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.4.tgz", + "integrity": "sha512-XxNdAsKW7C+FLydqFJLb5KhJtl3PGCMmYwFRfhvIgxJvLSXhhVI1zM8f1qD3Zg7RCjTSzDVyct6sghs9UEgBEQ==", "dev": true, "license": "MIT", "funding": { @@ -1146,13 +1805,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.3.tgz", - "integrity": "sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.4.tgz", + "integrity": "sha512-13QMT+eysM5uVGa1rG4kegGYNp6cnQcsTc67ELFbhNLQO+vgsygtYJx2khvdt4gVQqSSpC/KT5FZZxUpP3Oatw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.3", + "@vitest/pretty-format": "4.1.4", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1228,12 +1887,16 @@ } }, "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==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, "engines": { - "node": ">=12" + "node": ">=8" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" @@ -1432,10 +2095,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/baseline-browser-mapping": { - "version": "2.10.13", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", - "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "version": "2.10.17", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.17.tgz", + "integrity": "sha512-HdrkN8eVG2CXxeifv/VdJ4A4RSra1DTW8dc/hdxzhGHN8QePs6gKaWM9pHPcpCoxYZJuOZ8drHmbdpLHjCYjLA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1445,6 +2118,19 @@ "node": ">=6.0.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -1480,15 +2166,15 @@ } }, "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" }, "engines": { @@ -1540,9 +2226,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001784", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz", - "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==", + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", "dev": true, "funding": [ { @@ -1570,6 +2256,23 @@ "node": ">=18" } }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "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", @@ -1613,22 +2316,6 @@ "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", @@ -1860,9 +2547,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.331", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", - "integrity": "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q==", + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", "dev": true, "license": "ISC" }, @@ -1885,9 +2572,9 @@ } }, "node_modules/es-abstract": { - "version": "1.24.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", - "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", "dev": true, "license": "MIT", "dependencies": { @@ -1974,16 +2661,16 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", - "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.8", + "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.24.1", + "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", @@ -1995,8 +2682,7 @@ "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", - "math-intrinsics": "^1.1.0", - "safe-array-concat": "^1.1.3" + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -2080,9 +2766,9 @@ ] }, "node_modules/esbuild": { - "version": "0.27.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.5.tgz", - "integrity": "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA==", + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -2093,32 +2779,32 @@ "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" + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" } }, "node_modules/escalade": { @@ -2131,6 +2817,19 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint": { "version": "9.39.4", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", @@ -2261,6 +2960,24 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, + "node_modules/eslint-plugin-react/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-react/node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/eslint-plugin-react/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -2274,24 +2991,6 @@ "node": "*" } }, - "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", - "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/minimatch/node_modules/brace-expansion/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/eslint-plugin-react/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2335,20 +3034,17 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/balanced-match": { @@ -2369,36 +3065,6 @@ "concat-map": "0.0.1" } }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -2638,6 +3304,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -3087,6 +3768,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ink/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/ink/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -3099,37 +3792,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "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/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3726,6 +4388,153 @@ "lightningcss-win32-x64-msvc": "1.32.0" } }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lightningcss-linux-x64-gnu": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", @@ -3768,6 +4577,48 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -3843,6 +4694,22 @@ "node": ">=6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4250,9 +5117,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", "dev": true, "license": "MIT", "bin": { @@ -4288,9 +5155,9 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4423,14 +5290,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.13", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", - "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.123.0", - "@rolldown/pluginutils": "1.0.0-rc.13" + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" }, "bin": { "rolldown": "bin/cli.mjs" @@ -4439,21 +5306,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.13", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", - "@rolldown/binding-darwin-x64": "1.0.0-rc.13", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, "node_modules/safe-array-concat": { @@ -4517,6 +5384,19 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4610,14 +5490,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4694,6 +5574,18 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, + "node_modules/slice-ansi/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/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4753,6 +5645,22 @@ "node": ">= 0.4" } }, + "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/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -4947,14 +5855,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -4986,6 +5894,14 @@ "typescript": ">=4.8.4" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -5019,6 +5935,21 @@ "node": ">= 0.8.0" } }, + "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/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -5131,9 +6062,9 @@ } }, "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==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -5192,16 +6123,16 @@ } }, "node_modules/vite": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", - "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.13", + "rolldown": "1.0.0-rc.15", "tinyglobby": "^0.2.15" }, "bin": { @@ -5270,19 +6201,19 @@ } }, "node_modules/vitest": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.3.tgz", - "integrity": "sha512-DBc4Tx0MPNsqb9isoyOq00lHftVx/KIU44QOm2q59npZyLUkENn8TMFsuzuO+4U2FUa9rgbbPt3udrP25GcjXw==", + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.4.tgz", + "integrity": "sha512-tFuJqTxKb8AvfyqMfnavXdzfy3h3sWZRWwfluGbkeR7n0HUev+FmNgZ8SDrRBTVrVCjgH5cA21qGbCffMNtWvg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.3", - "@vitest/mocker": "4.1.3", - "@vitest/pretty-format": "4.1.3", - "@vitest/runner": "4.1.3", - "@vitest/snapshot": "4.1.3", - "@vitest/spy": "4.1.3", - "@vitest/utils": "4.1.3", + "@vitest/expect": "4.1.4", + "@vitest/mocker": "4.1.4", + "@vitest/pretty-format": "4.1.4", + "@vitest/runner": "4.1.4", + "@vitest/snapshot": "4.1.4", + "@vitest/spy": "4.1.4", + "@vitest/utils": "4.1.4", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -5310,12 +6241,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.3", - "@vitest/browser-preview": "4.1.3", - "@vitest/browser-webdriverio": "4.1.3", - "@vitest/coverage-istanbul": "4.1.3", - "@vitest/coverage-v8": "4.1.3", - "@vitest/ui": "4.1.3", + "@vitest/browser-playwright": "4.1.4", + "@vitest/browser-preview": "4.1.4", + "@vitest/browser-webdriverio": "4.1.4", + "@vitest/coverage-istanbul": "4.1.4", + "@vitest/coverage-v8": "4.1.4", + "@vitest/ui": "4.1.4", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5496,22 +6427,6 @@ "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/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -5539,6 +6454,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi/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/wrap-ansi/node_modules/string-width": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", From 294c377c0c57aabbe545b62d4bfd7980c583dc18 Mon Sep 17 00:00:00 2001 From: jonny Date: Fri, 10 Apr 2026 09:18:06 +0000 Subject: [PATCH 062/157] fix(tui): use PROJECT_ROOT instead of cwd for HERMES_ROOT fallback When HERMES_ROOT was added for Nix-bundled TUI support, the fallback was set to os.getcwd(). This overrode the TUI's own import.meta.dirname resolution, so launching `hermes --tui` from outside the repo caused the gateway client to look for venv/bin/python relative to the user's working directory instead of the repo root. Use PROJECT_ROOT (resolved from the source file location) as the fallback, which is stable regardless of where the command is invoked. --- hermes_cli/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index e7455608cf..2ccd1ba9c6 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -663,7 +663,7 @@ def _launch_tui(resume_session_id: Optional[str] = None): tui_dir = PROJECT_ROOT / "ui-tui" env = os.environ.copy() - env["HERMES_ROOT"] = os.environ.get("HERMES_ROOT", os.getcwd()) + env["HERMES_ROOT"] = os.environ.get("HERMES_ROOT", str(PROJECT_ROOT)) if resume_session_id: env["HERMES_TUI_RESUME"] = resume_session_id From 304f1463a9aae82ec4fce47d4da9dabfd0598d70 Mon Sep 17 00:00:00 2001 From: jonny Date: Thu, 9 Apr 2026 12:17:55 +0000 Subject: [PATCH 063/157] fix(tui): show CLI sessions in resume picker - session.list RPC now queries both tui and cli sources, merged by recency - Session picker shows source label for non-tui sessions (e.g. ", cli") - Added source field to SessionItem interface --- tui_gateway/server.py | 10 ++++++++-- ui-tui/src/components/sessionPicker.tsx | 3 ++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c204e19047..e53694d1d4 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -449,10 +449,16 @@ def _(rid, params: dict) -> dict: @method("session.list") def _(rid, params: dict) -> dict: try: - rows = _get_db().list_sessions_rich(source="tui", limit=params.get("limit", 20)) + db = _get_db() + # Show both TUI and CLI sessions — TUI is the successor to the CLI, + # so users expect to resume their old CLI sessions here too. + tui = db.list_sessions_rich(source="tui", limit=params.get("limit", 20)) + cli = db.list_sessions_rich(source="cli", limit=params.get("limit", 20)) + rows = sorted(tui + cli, key=lambda s: s.get("started_at") or 0, reverse=True)[:params.get("limit", 20)] return _ok(rid, {"sessions": [ {"id": s["id"], "title": s.get("title") or "", "preview": s.get("preview") or "", - "started_at": s.get("started_at") or 0, "message_count": s.get("message_count") or 0} + "started_at": s.get("started_at") or 0, "message_count": s.get("message_count") or 0, + "source": s.get("source") or ""} for s in rows ]}) except Exception as e: diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index 9b5750b093..a793e52a59 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -10,6 +10,7 @@ interface SessionItem { preview: string started_at: number message_count: number + source?: string } function age(ts: number): string { @@ -109,7 +110,7 @@ export function SessionPicker({ {' '} - ({s.message_count} msgs, {age(s.started_at)}) + ({s.message_count} msgs, {age(s.started_at)}{s.source && s.source !== 'tui' ? `, ${s.source}` : ''}) ) From 90f0aa174dddc3b9cf63401fe3638b81c468e437 Mon Sep 17 00:00:00 2001 From: jonny Date: Thu, 9 Apr 2026 12:18:00 +0000 Subject: [PATCH 064/157] fix(tui): support /resume to bypass session picker - Extract resumeById callback from inline onSelect handler - /resume with no arg opens picker (unchanged behavior) - /resume resumes directly, skipping the picker --- ui-tui/src/app.tsx | 54 +++++++++++++++++++++++++--------------------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 25c87205c5..91f45eabfc 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -465,6 +465,33 @@ export function App({ gw }: { gw: GatewayClient }) { [rpc, sys] ) + const resumeById = useCallback( + (id: string) => { + setPicker(false) + setStatus('resuming…') + gw.request('session.resume', { cols: colsRef.current, session_id: id }) + .then((r: any) => { + resetSession() + setSid(r.session_id) + setInfo(r.info ?? null) + const resumed = toTranscriptMessages(r.messages) + + if (r.info?.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + setStatus('ready') + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + }) + }, + [gw, sys] + ) + // ── Paste pipeline ─────────────────────────────────────────────── const listPasteIds = useCallback((text: string) => { @@ -1344,7 +1371,8 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'resume': - setPicker(true) + if (arg) resumeById(arg) + else setPicker(true) return true @@ -2012,29 +2040,7 @@ export function App({ gw }: { gw: GatewayClient }) { setPicker(false)} - onSelect={id => { - setPicker(false) - setStatus('resuming…') - gw.request('session.resume', { cols: colsRef.current, session_id: id }) - .then((r: any) => { - resetSession() - setSid(r.session_id) - setInfo(r.info ?? null) - const resumed = toTranscriptMessages(r.messages) - - if (r.info?.usage) { - setUsage(prev => ({ ...prev, ...r.info.usage })) - } - - setMessages(resumed) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - setStatus('ready') - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - }) - }} + onSelect={resumeById} t={theme} /> From cb79018977850289c23d8f81aca9ed2b57d67022 Mon Sep 17 00:00:00 2001 From: jonny Date: Fri, 10 Apr 2026 11:16:41 +0000 Subject: [PATCH 065/157] fix(tui): improve session picker readability - Show full session ID in a fixed-width column for easy scanning - Pad row numbers to 2 digits to keep alignment past 9 entries - Always show session source (tui/cli) instead of conditionally hiding it - Use Box-based column layout so ID, metadata, and title don't run together --- ui-tui/src/components/sessionPicker.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index a793e52a59..41c033500c 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -103,16 +103,22 @@ export function SessionPicker({ const i = off + vi return ( - + {sel === i ? '▸ ' : ' '} + + + {String(i + 1).padStart(2)}. [{s.id}] + + + + + ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) + + - {i + 1}. {s.title || s.preview || s.id.slice(0, 8)} + {s.title || s.preview || '(untitled)'} - - {' '} - ({s.message_count} msgs, {age(s.started_at)}{s.source && s.source !== 'tui' ? `, ${s.source}` : ''}) - - + ) })} {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} From 57e8d44af8d7e6db6eb26dec6f8c0c1d27746d4b Mon Sep 17 00:00:00 2001 From: jonny Date: Sat, 11 Apr 2026 05:23:44 +0000 Subject: [PATCH 066/157] fix(tui): preserve tool metadata in resumed session history session.resume was building conversation history with only role and content, stripping tool_call_id, tool_calls, and tool_name. The API requires tool messages to reference their parent tool_call, so resumed sessions with tool history would fail with HTTP 500. Use get_messages_as_conversation() which already preserves the full message structure including tool metadata and reasoning fields. --- tui_gateway/server.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e53694d1d4..3c91201136 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -483,14 +483,13 @@ def _(rid, params: dict) -> dict: os.environ["HERMES_INTERACTIVE"] = "1" try: db.reopen_session(target) + history = db.get_messages_as_conversation(target) messages = [ - {"role": m["role"], "text": m["content"] or ""} - for m in db.get_messages(target) + {"role": m["role"], "text": m.get("content") or ""} + for m in history if m.get("role") in ("user", "assistant", "tool", "system") - and isinstance(m.get("content"), str) and (m.get("content") or "").strip() ] - history = [{"role": m["role"], "content": m["text"]} for m in messages] agent = _make_agent(sid, target, session_id=target) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: From cab6447d5831c583257b2e7233cf51930fa32399 Mon Sep 17 00:00:00 2001 From: jonny Date: Sat, 11 Apr 2026 06:35:00 +0000 Subject: [PATCH 067/157] fix(tui): render tool trail consistently between live and resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resumed sessions showed raw JSON tool output in content boxes instead of the compact trail lines seen during live use. The root cause was two separate rendering paths with no shared code. Extract buildToolTrailLine() into lib/text.ts as the single source of truth for formatting tool trail lines. Both the live tool.complete handler and toTranscriptMessages now call it. Server-side, reconstruct tool name and args from the assistant message's tool_calls field (tool_name column is unpopulated) and pass them through _tool_ctx/build_tool_preview — the same path the live tool.start callback uses. --- tui_gateway/server.py | 35 ++++++++++++++++++++++++----- ui-tui/src/app.tsx | 50 ++++++++++++++++++++++++++++-------------- ui-tui/src/lib/text.ts | 9 +++++++- 3 files changed, 70 insertions(+), 24 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 3c91201136..5f50ab6302 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -484,12 +484,35 @@ def _(rid, params: dict) -> dict: try: db.reopen_session(target) history = db.get_messages_as_conversation(target) - messages = [ - {"role": m["role"], "text": m.get("content") or ""} - for m in history - if m.get("role") in ("user", "assistant", "tool", "system") - and (m.get("content") or "").strip() - ] + messages = [] + tool_call_args = {} + for m in history: + role = m.get("role") + if role not in ("user", "assistant", "tool", "system"): + continue + if role == "assistant" and m.get("tool_calls"): + for tc in m["tool_calls"]: + fn = tc.get("function", {}) + tc_id = tc.get("id", "") + if tc_id and fn.get("name"): + try: + args = json.loads(fn.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + args = {} + tool_call_args[tc_id] = (fn["name"], args) + if not (m.get("content") or "").strip(): + continue + if role == "tool": + tc_id = m.get("tool_call_id", "") + tc_info = tool_call_args.get(tc_id) if tc_id else None + name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool" + args = (tc_info[1] if tc_info else None) or {} + ctx = _tool_ctx(name, args) + messages.append({"role": "tool", "name": name, "context": ctx}) + continue + if not (m.get("content") or "").strip(): + continue + messages.append({"role": role, "text": m.get("content") or ""}) agent = _make_agent(sid, target, session_id=target) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 91f45eabfc..e6eba62095 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,7 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' +import { buildToolTrailLine, compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -107,24 +107,41 @@ const toTranscriptMessages = (rows: unknown): Msg[] => { return [] } - return rows.flatMap(row => { - if (!row || typeof row !== 'object') { - return [] - } + const result: Msg[] = [] + let pendingTools: string[] = [] + + for (const row of rows) { + if (!row || typeof row !== 'object') continue const role = (row as any).role const text = (row as any).text - if ( - (role !== 'assistant' && role !== 'system' && role !== 'tool' && role !== 'user') || - typeof text !== 'string' || - !text.trim() - ) { - return [] + if (role === 'tool') { + const name = (row as any).name ?? 'tool' + const ctx = (row as any).context ?? '' + pendingTools.push(buildToolTrailLine(name, ctx)) + continue } - return [{ role, text }] - }) + if (typeof text !== 'string' || !text.trim()) continue + + if (role === 'assistant') { + const msg: Msg = { role, text } + if (pendingTools.length) { + msg.tools = pendingTools + pendingTools = [] + } + result.push(msg) + continue + } + + if (role === 'user' || role === 'system') { + pendingTools = [] + result.push({ role, text }) + } + } + + return result } // ── StatusRule ──────────────────────────────────────────────────────── @@ -1155,14 +1172,13 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.complete': { - const mark = p.error ? '✗' : '✓' - toolCompleteRibbonRef.current = null setTools(prev => { const done = prev.find(t => t.id === p.tool_id) - const label = TOOL_VERBS[done?.name ?? p.name] ?? done?.name ?? p.name + const name = done?.name ?? p.name const ctx = (p.error as string) || done?.context || '' - const line = `${label}${ctx ? ': ' + compactPreview(ctx, 72) : ''} ${mark}` + const label = TOOL_VERBS[name] ?? name + const line = buildToolTrailLine(name, ctx, !!p.error) toolCompleteRibbonRef.current = { label, line } const remaining = prev.filter(t => t.id !== p.tool_id) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 7f835c0cd4..e1364d8ded 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,4 @@ -import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' +import { INTERPOLATION_RE, LONG_MSG, TOOL_VERBS } from '../constants.js' // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*m/g @@ -35,6 +35,13 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +/** Build a single tool trail line — used by both live tool.complete and resume replay. */ +export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { + const label = TOOL_VERBS[name] ?? name + const mark = error ? '✗' : '✓' + return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` +} + /** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') From 8760faf991ec13231ab790bdf3d5ab1d86850770 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 11:29:08 -0500 Subject: [PATCH 068/157] feat: fork ink and make it work nicely --- ui-tui/eslint.config.mjs | 25 + ui-tui/package-lock.json | 172 +- ui-tui/package.json | 9 +- ui-tui/packages/hermes-ink/index.d.ts | 34 + ui-tui/packages/hermes-ink/index.js | 25 + ui-tui/packages/hermes-ink/package.json | 48 + .../hermes-ink/src/bootstrap/state.ts | 9 + .../hermes-ink/src/hooks/use-stderr.ts | 15 + .../hermes-ink/src/hooks/use-stdout.ts | 15 + ui-tui/packages/hermes-ink/src/ink/Ansi.tsx | 434 +++ ui-tui/packages/hermes-ink/src/ink/bidi.ts | 145 + .../hermes-ink/src/ink/clearTerminal.ts | 68 + .../packages/hermes-ink/src/ink/colorize.ts | 233 ++ .../src/ink/components/AlternateScreen.tsx | 93 + .../hermes-ink/src/ink/components/App.tsx | 748 ++++++ .../src/ink/components/AppContext.ts | 20 + .../hermes-ink/src/ink/components/Box.tsx | 265 ++ .../hermes-ink/src/ink/components/Button.tsx | 236 ++ .../src/ink/components/ClockContext.tsx | 133 + .../components/CursorDeclarationContext.ts | 28 + .../src/ink/components/ErrorOverview.tsx | 130 + .../hermes-ink/src/ink/components/Link.tsx | 53 + .../hermes-ink/src/ink/components/Newline.tsx | 43 + .../src/ink/components/NoSelect.tsx | 73 + .../hermes-ink/src/ink/components/RawAnsi.tsx | 61 + .../src/ink/components/ScrollBox.tsx | 285 ++ .../hermes-ink/src/ink/components/Spacer.tsx | 23 + .../src/ink/components/StdinContext.ts | 25 + .../ink/components/TerminalFocusContext.tsx | 63 + .../ink/components/TerminalSizeContext.tsx | 7 + .../hermes-ink/src/ink/components/Text.tsx | 296 +++ .../packages/hermes-ink/src/ink/constants.ts | 6 + ui-tui/packages/hermes-ink/src/ink/cursor.ts | 5 + ui-tui/packages/hermes-ink/src/ink/dom.ts | 485 ++++ .../hermes-ink/src/ink/events/click-event.ts | 38 + .../hermes-ink/src/ink/events/dispatcher.ts | 242 ++ .../hermes-ink/src/ink/events/emitter.ts | 40 + .../src/ink/events/event-handlers.ts | 73 + .../hermes-ink/src/ink/events/event.ts | 11 + .../hermes-ink/src/ink/events/focus-event.ts | 18 + .../hermes-ink/src/ink/events/input-event.ts | 184 ++ .../src/ink/events/keyboard-event.ts | 57 + .../src/ink/events/terminal-event.ts | 107 + .../src/ink/events/terminal-focus-event.ts | 19 + ui-tui/packages/hermes-ink/src/ink/focus.ts | 219 ++ ui-tui/packages/hermes-ink/src/ink/frame.ts | 116 + .../hermes-ink/src/ink/get-max-width.ts | 27 + .../packages/hermes-ink/src/ink/global.d.ts | 1 + .../packages/hermes-ink/src/ink/hit-test.ts | 146 ++ .../src/ink/hooks/use-animation-frame.ts | 62 + .../hermes-ink/src/ink/hooks/use-app.ts | 9 + .../src/ink/hooks/use-declared-cursor.ts | 75 + .../hermes-ink/src/ink/hooks/use-input.ts | 95 + .../hermes-ink/src/ink/hooks/use-interval.ts | 71 + .../src/ink/hooks/use-search-highlight.ts | 56 + .../hermes-ink/src/ink/hooks/use-selection.ts | 97 + .../hermes-ink/src/ink/hooks/use-stdin.ts | 9 + .../src/ink/hooks/use-tab-status.ts | 71 + .../src/ink/hooks/use-terminal-focus.ts | 18 + .../src/ink/hooks/use-terminal-title.ts | 34 + .../src/ink/hooks/use-terminal-viewport.ts | 100 + ui-tui/packages/hermes-ink/src/ink/ink.tsx | 2140 +++++++++++++++ .../packages/hermes-ink/src/ink/instances.ts | 10 + .../hermes-ink/src/ink/layout/engine.ts | 6 + .../hermes-ink/src/ink/layout/geometry.ts | 98 + .../hermes-ink/src/ink/layout/node.ts | 145 + .../hermes-ink/src/ink/layout/yoga.ts | 313 +++ .../hermes-ink/src/ink/line-width-cache.ts | 28 + .../packages/hermes-ink/src/ink/log-update.ts | 738 ++++++ .../hermes-ink/src/ink/measure-element.ts | 23 + .../hermes-ink/src/ink/measure-text.ts | 50 + .../packages/hermes-ink/src/ink/node-cache.ts | 53 + .../packages/hermes-ink/src/ink/optimizer.ts | 99 + ui-tui/packages/hermes-ink/src/ink/output.ts | 808 ++++++ .../hermes-ink/src/ink/parse-keypress.ts | 833 ++++++ .../packages/hermes-ink/src/ink/reconciler.ts | 532 ++++ .../hermes-ink/src/ink/render-border.ts | 206 ++ .../src/ink/render-node-to-output.ts | 1529 +++++++++++ .../hermes-ink/src/ink/render-to-screen.ts | 241 ++ .../packages/hermes-ink/src/ink/renderer.ts | 167 ++ ui-tui/packages/hermes-ink/src/ink/root.ts | 174 ++ ui-tui/packages/hermes-ink/src/ink/screen.ts | 1543 +++++++++++ .../hermes-ink/src/ink/searchHighlight.ts | 91 + .../packages/hermes-ink/src/ink/selection.ts | 1071 ++++++++ .../hermes-ink/src/ink/squash-text-nodes.ts | 74 + .../hermes-ink/src/ink/stringWidth.ts | 275 ++ ui-tui/packages/hermes-ink/src/ink/styles.ts | 749 ++++++ .../hermes-ink/src/ink/supports-hyperlinks.ts | 51 + .../packages/hermes-ink/src/ink/tabstops.ts | 44 + .../src/ink/terminal-focus-state.ts | 52 + .../hermes-ink/src/ink/terminal-querier.ts | 222 ++ .../packages/hermes-ink/src/ink/terminal.ts | 282 ++ ui-tui/packages/hermes-ink/src/ink/termio.ts | 42 + .../hermes-ink/src/ink/termio/ansi.ts | 75 + .../packages/hermes-ink/src/ink/termio/csi.ts | 334 +++ .../packages/hermes-ink/src/ink/termio/dec.ts | 54 + .../packages/hermes-ink/src/ink/termio/esc.ts | 69 + .../packages/hermes-ink/src/ink/termio/osc.ts | 554 ++++ .../hermes-ink/src/ink/termio/parser.ts | 467 ++++ .../packages/hermes-ink/src/ink/termio/sgr.ts | 362 +++ .../hermes-ink/src/ink/termio/tokenize.ts | 316 +++ .../hermes-ink/src/ink/termio/types.ts | 230 ++ .../src/ink/useTerminalNotification.ts | 110 + ui-tui/packages/hermes-ink/src/ink/warn.ts | 15 + .../hermes-ink/src/ink/widest-line.ts | 22 + .../packages/hermes-ink/src/ink/wrap-text.ts | 75 + .../packages/hermes-ink/src/ink/wrapAnsi.ts | 13 + .../src/native-ts/yoga-layout/enums.ts | 112 + .../src/native-ts/yoga-layout/index.ts | 2326 +++++++++++++++++ ui-tui/packages/hermes-ink/src/utils/debug.ts | 6 + .../hermes-ink/src/utils/earlyInput.ts | 131 + ui-tui/packages/hermes-ink/src/utils/env.ts | 41 + .../packages/hermes-ink/src/utils/envUtils.ts | 13 + .../hermes-ink/src/utils/execFileNoThrow.ts | 64 + .../hermes-ink/src/utils/fullscreen.ts | 3 + ui-tui/packages/hermes-ink/src/utils/intl.ts | 87 + ui-tui/packages/hermes-ink/src/utils/log.ts | 7 + .../packages/hermes-ink/src/utils/semver.ts | 57 + .../hermes-ink/src/utils/sliceAnsi.ts | 58 + ui-tui/packages/hermes-ink/text-input.d.ts | 2 + ui-tui/packages/hermes-ink/text-input.js | 1 + ui-tui/src/app.tsx | 78 +- ui-tui/src/components/activityLane.tsx | 2 +- ui-tui/src/components/branding.tsx | 12 +- ui-tui/src/components/markdown.tsx | 2 +- ui-tui/src/components/maskedPrompt.tsx | 3 +- ui-tui/src/components/messageLine.tsx | 12 +- ui-tui/src/components/pasteShelf.tsx | 2 +- ui-tui/src/components/prompts.tsx | 5 +- ui-tui/src/components/queuedMessages.tsx | 2 +- ui-tui/src/components/sessionPicker.tsx | 6 +- ui-tui/src/components/textInput.tsx | 271 +- ui-tui/src/components/thinking.tsx | 4 +- ui-tui/src/entry.tsx | 6 +- ui-tui/src/hooks/useCompletion.ts | 10 +- ui-tui/src/lib/text.ts | 3 + ui-tui/src/types/hermes-ink.d.ts | 65 + ui-tui/tsconfig.build.json | 9 + ui-tui/tsconfig.json | 2 +- 139 files changed, 24952 insertions(+), 140 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/index.d.ts create mode 100644 ui-tui/packages/hermes-ink/index.js create mode 100644 ui-tui/packages/hermes-ink/package.json create mode 100644 ui-tui/packages/hermes-ink/src/bootstrap/state.ts create mode 100644 ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts create mode 100644 ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/Ansi.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/bidi.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/colorize.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/App.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/Box.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/Button.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/CursorDeclarationContext.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/Link.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/StdinContext.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/TerminalSizeContext.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/components/Text.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/constants.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/cursor.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/dom.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/click-event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/emitter.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/input-event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/terminal-event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/terminal-focus-event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/focus.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/frame.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/get-max-width.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/global.d.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hit-test.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-animation-frame.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-app.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-declared-cursor.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-input.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-interval.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-search-highlight.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-stdin.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-tab-status.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-focus.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-viewport.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/ink.tsx create mode 100644 ui-tui/packages/hermes-ink/src/ink/instances.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/layout/engine.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/layout/geometry.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/layout/node.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/layout/yoga.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/log-update.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/measure-element.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/measure-text.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/node-cache.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/optimizer.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/output.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/reconciler.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/render-border.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/renderer.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/root.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/screen.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/searchHighlight.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/selection.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/stringWidth.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/styles.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/supports-hyperlinks.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/tabstops.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/terminal-focus-state.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/terminal-querier.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/terminal.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/ansi.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/csi.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/dec.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/esc.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/osc.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/parser.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/sgr.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/termio/types.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/useTerminalNotification.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/warn.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/widest-line.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/wrap-text.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/wrapAnsi.ts create mode 100644 ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/enums.ts create mode 100644 ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/debug.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/earlyInput.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/env.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/envUtils.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/fullscreen.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/intl.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/log.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/semver.ts create mode 100644 ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts create mode 100644 ui-tui/packages/hermes-ink/text-input.d.ts create mode 100644 ui-tui/packages/hermes-ink/text-input.js create mode 100644 ui-tui/src/types/hermes-ink.d.ts create mode 100644 ui-tui/tsconfig.build.json diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs index 905e734b8d..7013dfdb6e 100644 --- a/ui-tui/eslint.config.mjs +++ b/ui-tui/eslint.config.mjs @@ -7,6 +7,21 @@ import hooksPlugin from 'eslint-plugin-react-hooks' import unusedImports from 'eslint-plugin-unused-imports' import globals from 'globals' +const noopRule = { + meta: { schema: [], type: 'problem' }, + create: () => ({}) +} + +const customRules = { + rules: { + 'no-process-cwd': noopRule, + 'no-process-env-top-level': noopRule, + 'no-sync-fs': noopRule, + 'no-top-level-dynamic-import': noopRule, + 'no-top-level-side-effects': noopRule + } +} + export default [ js.configs.recommended, { @@ -22,6 +37,7 @@ export default [ }, plugins: { '@typescript-eslint': typescriptEslint, + 'custom-rules': customRules, perfectionist, react: reactPlugin, 'react-hooks': hooksPlugin, @@ -63,6 +79,15 @@ export default [ react: { version: 'detect' } } }, + { + files: ['packages/hermes-ink/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/consistent-type-imports': 'off', + 'no-constant-condition': 'off', + 'no-empty': 'off', + 'no-redeclare': 'off' + } + }, { ignores: ['node_modules/', 'dist/', '*.config.*', 'src/**/*.js'] } diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 1a0cb48596..ec79588fec 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -8,6 +8,7 @@ "name": "hermes-tui", "version": "0.0.1", "dependencies": { + "@hermes/ink": "file:./packages/hermes-ink", "ink": "^6.8.0", "ink-text-input": "^6.0.0", "react": "^19.2.4", @@ -1008,6 +1009,10 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@hermes/ink": { + "resolved": "packages/hermes-ink", + "link": true + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2118,6 +2123,15 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -3535,7 +3549,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -4635,6 +4648,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -5229,6 +5254,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "2.0.0-next.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", @@ -5388,7 +5422,6 @@ "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -5791,7 +5824,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -5800,6 +5832,22 @@ "node": ">=8" } }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -6122,6 +6170,21 @@ "punycode": "^2.1.0" } }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/vite": { "version": "8.0.8", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", @@ -6552,6 +6615,109 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "packages/hermes-ink": { + "name": "@hermes/ink", + "version": "0.0.1", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.0", + "auto-bind": "^5.0.0", + "bidi-js": "^1.0.0", + "chalk": "^5.4.0", + "cli-boxes": "^3.0.0", + "code-excerpt": "^4.0.0", + "emoji-regex": "^10.4.0", + "get-east-asian-width": "^1.3.0", + "indent-string": "^5.0.0", + "lodash-es": "^4.17.0", + "react": ">=19.0.0", + "react-reconciler": "0.33.0", + "semver": "^7.6.0", + "signal-exit": "^4.1.0", + "stack-utils": "^2.0.0", + "strip-ansi": "^7.1.0", + "supports-hyperlinks": "^3.1.0", + "type-fest": "^4.30.0", + "usehooks-ts": "^3.1.0", + "wrap-ansi": "^9.0.0" + }, + "peerDependencies": { + "ink-text-input": ">=6.0.0", + "react": ">=19.0.0" + } + }, + "packages/hermes-ink/node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", + "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "packages/hermes-ink/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" + } + }, + "packages/hermes-ink/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" + } + }, + "packages/hermes-ink/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "packages/hermes-ink/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "packages/hermes-ink/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" + } } } } diff --git a/ui-tui/package.json b/ui-tui/package.json index 177cdd05a0..2fc6271f8c 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -6,15 +6,16 @@ "scripts": { "dev": "tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", - "build": "tsc && chmod +x dist/entry.js", - "lint": "eslint src/", - "lint:fix": "eslint src/ --fix", - "fmt": "prettier --write 'src/**/*.{ts,tsx}'", + "build": "tsc -p tsconfig.build.json && chmod +x dist/entry.js", + "lint": "eslint src/ packages/", + "lint:fix": "eslint src/ packages/ --fix", + "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'", "fix": "npm run lint:fix && npm run fmt", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { + "@hermes/ink": "file:./packages/hermes-ink", "ink": "^6.8.0", "ink-text-input": "^6.0.0", "react": "^19.2.4", diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts new file mode 100644 index 0000000000..1c23959a35 --- /dev/null +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -0,0 +1,34 @@ +export { default as useStderr } from './src/hooks/use-stderr.ts' +export type { StderrHandle } from './src/hooks/use-stderr.ts' +export { default as useStdout } from './src/hooks/use-stdout.ts' +export type { StdoutHandle } from './src/hooks/use-stdout.ts' +export { Ansi } from './src/ink/Ansi.tsx' +export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx' +export { default as Box } from './src/ink/components/Box.tsx' +export type { Props as BoxProps } from './src/ink/components/Box.tsx' +export { default as Link } from './src/ink/components/Link.tsx' +export { default as Newline } from './src/ink/components/Newline.tsx' +export { NoSelect } from './src/ink/components/NoSelect.tsx' +export { RawAnsi } from './src/ink/components/RawAnsi.tsx' +export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx' +export type { ScrollBoxHandle, ScrollBoxProps } from './src/ink/components/ScrollBox.tsx' +export { default as Spacer } from './src/ink/components/Spacer.tsx' +export type { Props as StdinProps } from './src/ink/components/StdinContext.ts' +export { default as Text } from './src/ink/components/Text.tsx' +export type { Props as TextProps } from './src/ink/components/Text.tsx' +export type { Key } from './src/ink/events/input-event.ts' +export { default as useApp } from './src/ink/hooks/use-app.ts' +export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts' +export { default as useInput } from './src/ink/hooks/use-input.ts' +export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts' +export { default as useStdin } from './src/ink/hooks/use-stdin.ts' +export { useTabStatus } from './src/ink/hooks/use-tab-status.ts' +export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts' +export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts' +export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts' +export { default as measureElement } from './src/ink/measure-element.ts' +export { createRoot, default as render, renderSync } from './src/ink/root.ts' +export type { Instance, RenderOptions, Root } from './src/ink/root.ts' +export { stringWidth } from './src/ink/stringWidth.ts' +export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' +export type { Props as TextInputProps } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/index.js b/ui-tui/packages/hermes-ink/index.js new file mode 100644 index 0000000000..be929ce6ca --- /dev/null +++ b/ui-tui/packages/hermes-ink/index.js @@ -0,0 +1,25 @@ +export { default as render, createRoot, renderSync } from './src/ink/root.ts' +export { default as Box } from './src/ink/components/Box.tsx' +export { default as Text } from './src/ink/components/Text.tsx' +export { Ansi } from './src/ink/Ansi.tsx' +export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx' +export { default as Link } from './src/ink/components/Link.tsx' +export { default as Newline } from './src/ink/components/Newline.tsx' +export { NoSelect } from './src/ink/components/NoSelect.tsx' +export { RawAnsi } from './src/ink/components/RawAnsi.tsx' +export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx' +export { default as Spacer } from './src/ink/components/Spacer.tsx' +export { default as measureElement } from './src/ink/measure-element.ts' +export { stringWidth } from './src/ink/stringWidth.ts' +export { default as useApp } from './src/ink/hooks/use-app.ts' +export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts' +export { default as useInput } from './src/ink/hooks/use-input.ts' +export { default as useStdin } from './src/ink/hooks/use-stdin.ts' +export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts' +export { default as useStdout } from './src/hooks/use-stdout.ts' +export { default as useStderr } from './src/hooks/use-stderr.ts' +export { useTabStatus } from './src/ink/hooks/use-tab-status.ts' +export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts' +export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts' +export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts' +export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/package.json b/ui-tui/packages/hermes-ink/package.json new file mode 100644 index 0000000000..6741a24f93 --- /dev/null +++ b/ui-tui/packages/hermes-ink/package.json @@ -0,0 +1,48 @@ +{ + "name": "@hermes/ink", + "version": "0.0.1", + "private": true, + "type": "module", + "sideEffects": false, + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.js", + "default": "./index.js", + "types": "./index.d.ts" + }, + "./text-input": { + "import": "./text-input.js", + "default": "./text-input.js", + "types": "./text-input.d.ts" + }, + "./package.json": "./package.json" + }, + "peerDependencies": { + "ink-text-input": ">=6.0.0", + "react": ">=19.0.0" + }, + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.0", + "auto-bind": "^5.0.0", + "bidi-js": "^1.0.0", + "chalk": "^5.4.0", + "cli-boxes": "^3.0.0", + "code-excerpt": "^4.0.0", + "emoji-regex": "^10.4.0", + "get-east-asian-width": "^1.3.0", + "indent-string": "^5.0.0", + "lodash-es": "^4.17.0", + "react": ">=19.0.0", + "react-reconciler": "0.33.0", + "semver": "^7.6.0", + "signal-exit": "^4.1.0", + "stack-utils": "^2.0.0", + "strip-ansi": "^7.1.0", + "supports-hyperlinks": "^3.1.0", + "type-fest": "^4.30.0", + "usehooks-ts": "^3.1.0", + "wrap-ansi": "^9.0.0" + } +} diff --git a/ui-tui/packages/hermes-ink/src/bootstrap/state.ts b/ui-tui/packages/hermes-ink/src/bootstrap/state.ts new file mode 100644 index 0000000000..dcbae499fc --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/bootstrap/state.ts @@ -0,0 +1,9 @@ +export function flushInteractionTime(): void {} + +export function updateLastInteractionTime(): void {} + +export function markScrollActivity(): void {} + +export function getIsInteractive(): boolean { + return !!process.stdin.isTTY && !!process.stdout.isTTY +} diff --git a/ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts b/ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts new file mode 100644 index 0000000000..0aa7e1f20a --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/hooks/use-stderr.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react' +export type StderrHandle = { + stderr: NodeJS.WriteStream + write: (data: string) => boolean +} + +export default function useStderr(): StderrHandle { + return useMemo( + () => ({ + stderr: process.stderr, + write: data => process.stderr.write(data) + }), + [] + ) +} diff --git a/ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts b/ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts new file mode 100644 index 0000000000..fde397af2b --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/hooks/use-stdout.ts @@ -0,0 +1,15 @@ +import { useMemo } from 'react' +export type StdoutHandle = { + stdout: NodeJS.WriteStream + write: (data: string) => boolean +} + +export default function useStdout(): StdoutHandle { + return useMemo( + () => ({ + stdout: process.stdout, + write: data => process.stdout.write(data) + }), + [] + ) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/Ansi.tsx b/ui-tui/packages/hermes-ink/src/ink/Ansi.tsx new file mode 100644 index 0000000000..e37eca558f --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/Ansi.tsx @@ -0,0 +1,434 @@ +import React from 'react' +import { c as _c } from 'react/compiler-runtime' + +import Link from './components/Link.js' +import Text from './components/Text.js' +import type { Color } from './styles.js' +import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js' +type Props = { + children: string + /** When true, force all text to be rendered with dim styling */ + dimColor?: boolean +} +type SpanProps = { + color?: Color + backgroundColor?: Color + dim?: boolean + bold?: boolean + italic?: boolean + underline?: boolean + strikethrough?: boolean + inverse?: boolean + hyperlink?: string +} + +/** + * Component that parses ANSI escape codes and renders them using Text components. + * + * Use this as an escape hatch when you have pre-formatted ANSI strings from + * external tools (like cli-highlight) that need to be rendered in Ink. + * + * Memoized to prevent re-renders when parent changes but children string is the same. + */ +export const Ansi = React.memo(function Ansi(t0) { + const $ = _c(12) + + const { children, dimColor } = t0 + + if (typeof children !== 'string') { + let t1 + + if ($[0] !== children || $[1] !== dimColor) { + t1 = dimColor ? {String(children)} : {String(children)} + $[0] = children + $[1] = dimColor + $[2] = t1 + } else { + t1 = $[2] + } + + return t1 + } + + if (children === '') { + return null + } + + let t1 + let t2 + + if ($[3] !== children || $[4] !== dimColor) { + t2 = Symbol.for('react.early_return_sentinel') + + bb0: { + const spans = parseToSpans(children) + + if (spans.length === 0) { + t2 = null + + break bb0 + } + + if (spans.length === 1 && !hasAnyProps(spans[0].props)) { + t2 = dimColor ? {spans[0].text} : {spans[0].text} + + break bb0 + } + + let t3 + + if ($[7] !== dimColor) { + t3 = (span, i) => { + const hyperlink = span.props.hyperlink + + if (dimColor) { + span.props.dim = true + } + + const hasTextProps = hasAnyTextProps(span.props) + + if (hyperlink) { + return hasTextProps ? ( + + + {span.text} + + + ) : ( + + {span.text} + + ) + } + + return hasTextProps ? ( + + {span.text} + + ) : ( + span.text + ) + } + + $[7] = dimColor + $[8] = t3 + } else { + t3 = $[8] + } + + t1 = spans.map(t3) + } + + $[3] = children + $[4] = dimColor + $[5] = t1 + $[6] = t2 + } else { + t1 = $[5] + t2 = $[6] + } + + if (t2 !== Symbol.for('react.early_return_sentinel')) { + return t2 + } + + const content = t1 + let t3 + + if ($[9] !== content || $[10] !== dimColor) { + t3 = dimColor ? {content} : {content} + $[9] = content + $[10] = dimColor + $[11] = t3 + } else { + t3 = $[11] + } + + return t3 +}) +type Span = { + text: string + props: SpanProps +} + +/** + * Parse an ANSI string into spans using the termio parser. + */ +function parseToSpans(input: string): Span[] { + const parser = new Parser() + const actions = parser.feed(input) + const spans: Span[] = [] + let currentHyperlink: string | undefined + + for (const action of actions) { + if (action.type === 'link') { + if (action.action.type === 'start') { + currentHyperlink = action.action.url + } else { + currentHyperlink = undefined + } + + continue + } + + if (action.type === 'text') { + const text = action.graphemes.map(g => g.value).join('') + + if (!text) { + continue + } + + const props = textStyleToSpanProps(action.style) + + if (currentHyperlink) { + props.hyperlink = currentHyperlink + } + + // Try to merge with previous span if props match + const lastSpan = spans[spans.length - 1] + + if (lastSpan && propsEqual(lastSpan.props, props)) { + lastSpan.text += text + } else { + spans.push({ + text, + props + }) + } + } + } + + return spans +} + +/** + * Convert termio's TextStyle to SpanProps. + */ +function textStyleToSpanProps(style: TextStyle): SpanProps { + const props: SpanProps = {} + + if (style.bold) { + props.bold = true + } + + if (style.dim) { + props.dim = true + } + + if (style.italic) { + props.italic = true + } + + if (style.underline !== 'none') { + props.underline = true + } + + if (style.strikethrough) { + props.strikethrough = true + } + + if (style.inverse) { + props.inverse = true + } + + const fgColor = colorToString(style.fg) + + if (fgColor) { + props.color = fgColor + } + + const bgColor = colorToString(style.bg) + + if (bgColor) { + props.backgroundColor = bgColor + } + + return props +} + +// Map termio named colors to the ansi: format +const NAMED_COLOR_MAP: Record = { + black: 'ansi:black', + red: 'ansi:red', + green: 'ansi:green', + yellow: 'ansi:yellow', + blue: 'ansi:blue', + magenta: 'ansi:magenta', + cyan: 'ansi:cyan', + white: 'ansi:white', + brightBlack: 'ansi:blackBright', + brightRed: 'ansi:redBright', + brightGreen: 'ansi:greenBright', + brightYellow: 'ansi:yellowBright', + brightBlue: 'ansi:blueBright', + brightMagenta: 'ansi:magentaBright', + brightCyan: 'ansi:cyanBright', + brightWhite: 'ansi:whiteBright' +} + +/** + * Convert termio's Color to the string format used by Ink. + */ +function colorToString(color: TermioColor): Color | undefined { + switch (color.type) { + case 'named': + return NAMED_COLOR_MAP[color.name] as Color + + case 'indexed': + return `ansi256(${color.index})` as Color + + case 'rgb': + return `rgb(${color.r},${color.g},${color.b})` as Color + + case 'default': + return undefined + } +} + +/** + * Check if two SpanProps are equal for merging. + */ +function propsEqual(a: SpanProps, b: SpanProps): boolean { + return ( + a.color === b.color && + a.backgroundColor === b.backgroundColor && + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.strikethrough === b.strikethrough && + a.inverse === b.inverse && + a.hyperlink === b.hyperlink + ) +} + +function hasAnyProps(props: SpanProps): boolean { + return ( + props.color !== undefined || + props.backgroundColor !== undefined || + props.dim === true || + props.bold === true || + props.italic === true || + props.underline === true || + props.strikethrough === true || + props.inverse === true || + props.hyperlink !== undefined + ) +} + +function hasAnyTextProps(props: SpanProps): boolean { + return ( + props.color !== undefined || + props.backgroundColor !== undefined || + props.dim === true || + props.bold === true || + props.italic === true || + props.underline === true || + props.strikethrough === true || + props.inverse === true + ) +} + +// Text style props without weight (bold/dim) - these are handled separately +type BaseTextStyleProps = { + color?: Color + backgroundColor?: Color + italic?: boolean + underline?: boolean + strikethrough?: boolean + inverse?: boolean +} + +// Wrapper component that handles bold/dim mutual exclusivity for Text +function StyledText(t0) { + const $ = _c(14) + let bold + let children + let dim + let rest + + if ($[0] !== t0) { + ;({ bold, dim, children, ...rest } = t0) + $[0] = t0 + $[1] = bold + $[2] = children + $[3] = dim + $[4] = rest + } else { + bold = $[1] + children = $[2] + dim = $[3] + rest = $[4] + } + + if (dim) { + let t1 + + if ($[5] !== children || $[6] !== rest) { + t1 = ( + + {children} + + ) + $[5] = children + $[6] = rest + $[7] = t1 + } else { + t1 = $[7] + } + + return t1 + } + + if (bold) { + let t1 + + if ($[8] !== children || $[9] !== rest) { + t1 = ( + + {children} + + ) + $[8] = children + $[9] = rest + $[10] = t1 + } else { + t1 = $[10] + } + + return t1 + } + + let t1 + + if ($[11] !== children || $[12] !== rest) { + t1 = {children} + $[11] = children + $[12] = rest + $[13] = t1 + } else { + t1 = $[13] + } + + return t1 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxpbmsiLCJUZXh0IiwiQ29sb3IiLCJOYW1lZENvbG9yIiwiUGFyc2VyIiwiVGVybWlvQ29sb3IiLCJUZXh0U3R5bGUiLCJQcm9wcyIsImNoaWxkcmVuIiwiZGltQ29sb3IiLCJTcGFuUHJvcHMiLCJjb2xvciIsImJhY2tncm91bmRDb2xvciIsImRpbSIsImJvbGQiLCJpdGFsaWMiLCJ1bmRlcmxpbmUiLCJzdHJpa2V0aHJvdWdoIiwiaW52ZXJzZSIsImh5cGVybGluayIsIkFuc2kiLCJtZW1vIiwidDAiLCIkIiwiX2MiLCJ0MSIsIlN0cmluZyIsInQyIiwiU3ltYm9sIiwiZm9yIiwiYmIwIiwic3BhbnMiLCJwYXJzZVRvU3BhbnMiLCJsZW5ndGgiLCJoYXNBbnlQcm9wcyIsInByb3BzIiwidGV4dCIsInQzIiwic3BhbiIsImkiLCJoYXNUZXh0UHJvcHMiLCJoYXNBbnlUZXh0UHJvcHMiLCJtYXAiLCJjb250ZW50IiwiU3BhbiIsImlucHV0IiwicGFyc2VyIiwiYWN0aW9ucyIsImZlZWQiLCJjdXJyZW50SHlwZXJsaW5rIiwiYWN0aW9uIiwidHlwZSIsInVybCIsInVuZGVmaW5lZCIsImdyYXBoZW1lcyIsImciLCJ2YWx1ZSIsImpvaW4iLCJ0ZXh0U3R5bGVUb1NwYW5Qcm9wcyIsInN0eWxlIiwibGFzdFNwYW4iLCJwcm9wc0VxdWFsIiwicHVzaCIsImZnQ29sb3IiLCJjb2xvclRvU3RyaW5nIiwiZmciLCJiZ0NvbG9yIiwiYmciLCJOQU1FRF9DT0xPUl9NQVAiLCJSZWNvcmQiLCJibGFjayIsInJlZCIsImdyZWVuIiwieWVsbG93IiwiYmx1ZSIsIm1hZ2VudGEiLCJjeWFuIiwid2hpdGUiLCJicmlnaHRCbGFjayIsImJyaWdodFJlZCIsImJyaWdodEdyZWVuIiwiYnJpZ2h0WWVsbG93IiwiYnJpZ2h0Qmx1ZSIsImJyaWdodE1hZ2VudGEiLCJicmlnaHRDeWFuIiwiYnJpZ2h0V2hpdGUiLCJuYW1lIiwiaW5kZXgiLCJyIiwiYiIsImEiLCJCYXNlVGV4dFN0eWxlUHJvcHMiLCJTdHlsZWRUZXh0IiwicmVzdCJdLCJzb3VyY2VzIjpbIkFuc2kudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCBMaW5rIGZyb20gJy4vY29tcG9uZW50cy9MaW5rLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9jb21wb25lbnRzL1RleHQuanMnXG5pbXBvcnQgdHlwZSB7IENvbG9yIH0gZnJvbSAnLi9zdHlsZXMuanMnXG5pbXBvcnQge1xuICB0eXBlIE5hbWVkQ29sb3IsXG4gIFBhcnNlcixcbiAgdHlwZSBDb2xvciBhcyBUZXJtaW9Db2xvcixcbiAgdHlwZSBUZXh0U3R5bGUsXG59IGZyb20gJy4vdGVybWlvLmpzJ1xuXG50eXBlIFByb3BzID0ge1xuICBjaGlsZHJlbjogc3RyaW5nXG4gIC8qKiBXaGVuIHRydWUsIGZvcmNlIGFsbCB0ZXh0IHRvIGJlIHJlbmRlcmVkIHdpdGggZGltIHN0eWxpbmcgKi9cbiAgZGltQ29sb3I/OiBib29sZWFuXG59XG5cbnR5cGUgU3BhblByb3BzID0ge1xuICBjb2xvcj86IENvbG9yXG4gIGJhY2tncm91bmRDb2xvcj86IENvbG9yXG4gIGRpbT86IGJvb2xlYW5cbiAgYm9sZD86IGJvb2xlYW5cbiAgaXRhbGljPzogYm9vbGVhblxuICB1bmRlcmxpbmU/OiBib29sZWFuXG4gIHN0cmlrZXRocm91Z2g/OiBib29sZWFuXG4gIGludmVyc2U/OiBib29sZWFuXG4gIGh5cGVybGluaz86IHN0cmluZ1xufVxuXG4vKipcbiAqIENvbXBvbmVudCB0aGF0IHBhcnNlcyBBTlNJIGVzY2FwZSBjb2RlcyBhbmQgcmVuZGVycyB0aGVtIHVzaW5nIFRleHQgY29tcG9uZW50cy5cbiAqXG4gKiBVc2UgdGhpcyBhcyBhbiBlc2NhcGUgaGF0Y2ggd2hlbiB5b3UgaGF2ZSBwcmUtZm9ybWF0dGVkIEFOU0kgc3RyaW5ncyBmcm9tXG4gKiBleHRlcm5hbCB0b29scyAobGlrZSBjbGktaGlnaGxpZ2h0KSB0aGF0IG5lZWQgdG8gYmUgcmVuZGVyZWQgaW4gSW5rLlxuICpcbiAqIE1lbW9pemVkIHRvIHByZXZlbnQgcmUtcmVuZGVycyB3aGVuIHBhcmVudCBjaGFuZ2VzIGJ1dCBjaGlsZHJlbiBzdHJpbmcgaXMgdGhlIHNhbWUuXG4gKi9cbmV4cG9ydCBjb25zdCBBbnNpID0gUmVhY3QubWVtbyhmdW5jdGlvbiBBbnNpKHtcbiAgY2hpbGRyZW4sXG4gIGRpbUNvbG9yLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBpZiAodHlwZW9mIGNoaWxkcmVuICE9PSAnc3RyaW5nJykge1xuICAgIHJldHVybiBkaW1Db2xvciA/IChcbiAgICAgIDxUZXh0IGRpbT57U3RyaW5nKGNoaWxkcmVuKX08L1RleHQ+XG4gICAgKSA6IChcbiAgICAgIDxUZXh0PntTdHJpbmcoY2hpbGRyZW4pfTwvVGV4dD5cbiAgICApXG4gIH1cblxuICBpZiAoY2hpbGRyZW4gPT09ICcnKSB7XG4gICAgcmV0dXJuIG51bGxcbiAgfVxuXG4gIGNvbnN0IHNwYW5zID0gcGFyc2VUb1NwYW5zKGNoaWxkcmVuKVxuXG4gIGlmIChzcGFucy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG5cbiAgaWYgKHNwYW5zLmxlbmd0aCA9PT0gMSAmJiAhaGFzQW55UHJvcHMoc3BhbnNbMF0hLnByb3BzKSkge1xuICAgIHJldHVybiBkaW1Db2xvciA/IChcbiAgICAgIDxUZXh0IGRpbT57c3BhbnNbMF0hLnRleHR9PC9UZXh0PlxuICAgICkgOiAoXG4gICAgICA8VGV4dD57c3BhbnNbMF0hLnRleHR9PC9UZXh0PlxuICAgIClcbiAgfVxuXG4gIGNvbnN0IGNvbnRlbnQgPSBzcGFucy5tYXAoKHNwYW4sIGkpID0+IHtcbiAgICBjb25zdCBoeXBlcmxpbmsgPSBzcGFuLnByb3BzLmh5cGVybGlua1xuICAgIC8vIFdoZW4gZGltQ29sb3IgaXMgZm9yY2VkLCBvdmVycmlkZSB0aGUgc3BhbidzIGRpbSBwcm9wXG4gICAgaWYgKGRpbUNvbG9yKSB7XG4gICAgICBzcGFuLnByb3BzLmRpbSA9IHRydWVcbiAgICB9XG4gICAgY29uc3QgaGFzVGV4dFByb3BzID0gaGFzQW55VGV4dFByb3BzKHNwYW4ucHJvcHMpXG5cbiAgICBpZiAoaHlwZXJsaW5rKSB7XG4gICAgICByZXR1cm4gaGFzVGV4dFByb3BzID8gKFxuICAgICAgICA8TGluayBrZXk9e2l9IHVybD17aHlwZXJsaW5rfT5cbiAgICAgICAgICA8U3R5bGVkVGV4dFxuICAgICAgICAgICAgY29sb3I9e3NwYW4ucHJvcHMuY29sb3J9XG4gICAgICAgICAgICBiYWNrZ3JvdW5kQ29sb3I9e3NwYW4ucHJvcHMuYmFja2dyb3VuZENvbG9yfVxuICAgICAgICAgICAgZGltPXtzcGFuLnByb3BzLmRpbX1cbiAgICAgICAgICAgIGJvbGQ9e3NwYW4ucHJvcHMuYm9sZH1cbiAgICAgICAgICAgIGl0YWxpYz17c3Bhbi5wcm9wcy5pdGFsaWN9XG4gICAgICAgICAgICB1bmRlcmxpbmU9e3NwYW4ucHJvcHMudW5kZXJsaW5lfVxuICAgICAgICAgICAgc3RyaWtldGhyb3VnaD17c3Bhbi5wcm9wcy5zdHJpa2V0aHJvdWdofVxuICAgICAgICAgICAgaW52ZXJzZT17c3Bhbi5wcm9wcy5pbnZlcnNlfVxuICAgICAgICAgID5cbiAgICAgICAgICAgIHtzcGFuLnRleHR9XG4gICAgICAgICAgPC9TdHlsZWRUZXh0PlxuICAgICAgICA8L0xpbms+XG4gICAgICApIDogKFxuICAgICAgICA8TGluayBrZXk9e2l9IHVybD17aHlwZXJsaW5rfT5cbiAgICAgICAgICB7c3Bhbi50ZXh0fVxuICAgICAgICA8L0xpbms+XG4gICAgICApXG4gICAgfVxuXG4gICAgcmV0dXJuIGhhc1RleHRQcm9wcyA/IChcbiAgICAgIDxTdHlsZWRUZXh0XG4gICAgICAgIGtleT17aX1cbiAgICAgICAgY29sb3I9e3NwYW4ucHJvcHMuY29sb3J9XG4gICAgICAgIGJhY2tncm91bmRDb2xvcj17c3Bhbi5wcm9wcy5iYWNrZ3JvdW5kQ29sb3J9XG4gICAgICAgIGRpbT17c3Bhbi5wcm9wcy5kaW19XG4gICAgICAgIGJvbGQ9e3NwYW4ucHJvcHMuYm9sZH1cbiAgICAgICAgaXRhbGljPXtzcGFuLnByb3BzLml0YWxpY31cbiAgICAgICAgdW5kZXJsaW5lPXtzcGFuLnByb3BzLnVuZGVybGluZX1cbiAgICAgICAgc3RyaWtldGhyb3VnaD17c3Bhbi5wcm9wcy5zdHJpa2V0aHJvdWdofVxuICAgICAgICBpbnZlcnNlPXtzcGFuLnByb3BzLmludmVyc2V9XG4gICAgICA+XG4gICAgICAgIHtzcGFuLnRleHR9XG4gICAgICA8L1N0eWxlZFRleHQ+XG4gICAgKSA6IChcbiAgICAgIHNwYW4udGV4dFxuICAgIClcbiAgfSlcblxuICByZXR1cm4gZGltQ29sb3IgPyA8VGV4dCBkaW0+e2NvbnRlbnR9PC9UZXh0PiA6IDxUZXh0Pntjb250ZW50fTwvVGV4dD5cbn0pXG5cbnR5cGUgU3BhbiA9IHtcbiAgdGV4dDogc3RyaW5nXG4gIHByb3BzOiBTcGFuUHJvcHNcbn1cblxuLyoqXG4gKiBQYXJzZSBhbiBBTlNJIHN0cmluZyBpbnRvIHNwYW5zIHVzaW5nIHRoZSB0ZXJtaW8gcGFyc2VyLlxuICovXG5mdW5jdGlvbiBwYXJzZVRvU3BhbnMoaW5wdXQ6IHN0cmluZyk6IFNwYW5bXSB7XG4gIGNvbnN0IHBhcnNlciA9IG5ldyBQYXJzZXIoKVxuICBjb25zdCBhY3Rpb25zID0gcGFyc2VyLmZlZWQoaW5wdXQpXG4gIGNvbnN0IHNwYW5zOiBTcGFuW10gPSBbXVxuXG4gIGxldCBjdXJyZW50SHlwZXJsaW5rOiBzdHJpbmcgfCB1bmRlZmluZWRcblxuICBmb3IgKGNvbnN0IGFjdGlvbiBvZiBhY3Rpb25zKSB7XG4gICAgaWYgKGFjdGlvbi50eXBlID09PSAnbGluaycpIHtcbiAgICAgIGlmIChhY3Rpb24uYWN0aW9uLnR5cGUgPT09ICdzdGFydCcpIHtcbiAgICAgICAgY3VycmVudEh5cGVybGluayA9IGFjdGlvbi5hY3Rpb24udXJsXG4gICAgICB9IGVsc2Uge1xuICAgICAgICBjdXJyZW50SHlwZXJsaW5rID0gdW5kZWZpbmVkXG4gICAgICB9XG4gICAgICBjb250aW51ZVxuICAgIH1cblxuICAgIGlmIChhY3Rpb24udHlwZSA9PT0gJ3RleHQnKSB7XG4gICAgICBjb25zdCB0ZXh0ID0gYWN0aW9uLmdyYXBoZW1lcy5tYXAoZyA9PiBnLnZhbHVlKS5qb2luKCcnKVxuICAgICAgaWYgKCF0ZXh0KSBjb250aW51ZVxuXG4gICAgICBjb25zdCBwcm9wcyA9IHRleHRTdHlsZVRvU3BhblByb3BzKGFjdGlvbi5zdHlsZSlcbiAgICAgIGlmIChjdXJyZW50SHlwZXJsaW5rKSB7XG4gICAgICAgIHByb3BzLmh5cGVybGluayA9IGN1cnJlbnRIeXBlcmxpbmtcbiAgICAgIH1cblxuICAgICAgLy8gVHJ5IHRvIG1lcmdlIHdpdGggcHJldmlvdXMgc3BhbiBpZiBwcm9wcyBtYXRjaFxuICAgICAgY29uc3QgbGFzdFNwYW4gPSBzcGFuc1tzcGFucy5sZW5ndGggLSAxXVxuICAgICAgaWYgKGxhc3RTcGFuICYmIHByb3BzRXF1YWwobGFzdFNwYW4ucHJvcHMsIHByb3BzKSkge1xuICAgICAgICBsYXN0U3Bhbi50ZXh0ICs9IHRleHRcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIHNwYW5zLnB1c2goeyB0ZXh0LCBwcm9wcyB9KVxuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIHJldHVybiBzcGFuc1xufVxuXG4vKipcbiAqIENvbnZlcnQgdGVybWlvJ3MgVGV4dFN0eWxlIHRvIFNwYW5Qcm9wcy5cbiAqL1xuZnVuY3Rpb24gdGV4dFN0eWxlVG9TcGFuUHJvcHMoc3R5bGU6IFRleHRTdHlsZSk6IFNwYW5Qcm9wcyB7XG4gIGNvbnN0IHByb3BzOiBTcGFuUHJvcHMgPSB7fVxuXG4gIGlmIChzdHlsZS5ib2xkKSBwcm9wcy5ib2xkID0gdHJ1ZVxuICBpZiAoc3R5bGUuZGltKSBwcm9wcy5kaW0gPSB0cnVlXG4gIGlmIChzdHlsZS5pdGFsaWMpIHByb3BzLml0YWxpYyA9IHRydWVcbiAgaWYgKHN0eWxlLnVuZGVybGluZSAhPT0gJ25vbmUnKSBwcm9wcy51bmRlcmxpbmUgPSB0cnVlXG4gIGlmIChzdHlsZS5zdHJpa2V0aHJvdWdoKSBwcm9wcy5zdHJpa2V0aHJvdWdoID0gdHJ1ZVxuICBpZiAoc3R5bGUuaW52ZXJzZSkgcHJvcHMuaW52ZXJzZSA9IHRydWVcblxuICBjb25zdCBmZ0NvbG9yID0gY29sb3JUb1N0cmluZyhzdHlsZS5mZylcbiAgaWYgKGZnQ29sb3IpIHByb3BzLmNvbG9yID0gZmdDb2xvclxuXG4gIGNvbnN0IGJnQ29sb3IgPSBjb2xvclRvU3RyaW5nKHN0eWxlLmJnKVxuICBpZiAoYmdDb2xvcikgcHJvcHMuYmFja2dyb3VuZENvbG9yID0gYmdDb2xvclxuXG4gIHJldHVybiBwcm9wc1xufVxuXG4vLyBNYXAgdGVybWlvIG5hbWVkIGNvbG9ycyB0byB0aGUgYW5zaTogZm9ybWF0XG5jb25zdCBOQU1FRF9DT0xPUl9NQVA6IFJlY29yZDxOYW1lZENvbG9yLCBzdHJpbmc+ID0ge1xuICBibGFjazogJ2Fuc2k6YmxhY2snLFxuICByZWQ6ICdhbnNpOnJlZCcsXG4gIGdyZWVuOiAnYW5zaTpncmVlbicsXG4gIHllbGxvdzogJ2Fuc2k6eWVsbG93JyxcbiAgYmx1ZTogJ2Fuc2k6Ymx1ZScsXG4gIG1hZ2VudGE6ICdhbnNpOm1hZ2VudGEnLFxuICBjeWFuOiAnYW5zaTpjeWFuJyxcbiAgd2hpdGU6ICdhbnNpOndoaXRlJyxcbiAgYnJpZ2h0QmxhY2s6ICdhbnNpOmJsYWNrQnJpZ2h0JyxcbiAgYnJpZ2h0UmVkOiAnYW5zaTpyZWRCcmlnaHQnLFxuICBicmlnaHRHcmVlbjogJ2Fuc2k6Z3JlZW5CcmlnaHQnLFxuICBicmlnaHRZZWxsb3c6ICdhbnNpOnllbGxvd0JyaWdodCcsXG4gIGJyaWdodEJsdWU6ICdhbnNpOmJsdWVCcmlnaHQnLFxuICBicmlnaHRNYWdlbnRhOiAnYW5zaTptYWdlbnRhQnJpZ2h0JyxcbiAgYnJpZ2h0Q3lhbjogJ2Fuc2k6Y3lhbkJyaWdodCcsXG4gIGJyaWdodFdoaXRlOiAnYW5zaTp3aGl0ZUJyaWdodCcsXG59XG5cbi8qKlxuICogQ29udmVydCB0ZXJtaW8ncyBDb2xvciB0byB0aGUgc3RyaW5nIGZvcm1hdCB1c2VkIGJ5IEluay5cbiAqL1xuZnVuY3Rpb24gY29sb3JUb1N0cmluZyhjb2xvcjogVGVybWlvQ29sb3IpOiBDb2xvciB8IHVuZGVmaW5lZCB7XG4gIHN3aXRjaCAoY29sb3IudHlwZSkge1xuICAgIGNhc2UgJ25hbWVkJzpcbiAgICAgIHJldHVybiBOQU1FRF9DT0xPUl9NQVBbY29sb3IubmFtZV0gYXMgQ29sb3JcbiAgICBjYXNlICdpbmRleGVkJzpcbiAgICAgIHJldHVybiBgYW5zaTI1Nigke2NvbG9yLmluZGV4fSlgIGFzIENvbG9yXG4gICAgY2FzZSAncmdiJzpcbiAgICAgIHJldHVybiBgcmdiKCR7Y29sb3Iucn0sJHtjb2xvci5nfSwke2NvbG9yLmJ9KWAgYXMgQ29sb3JcbiAgICBjYXNlICdkZWZhdWx0JzpcbiAgICAgIHJldHVybiB1bmRlZmluZWRcbiAgfVxufVxuXG4vKipcbiAqIENoZWNrIGlmIHR3byBTcGFuUHJvcHMgYXJlIGVxdWFsIGZvciBtZXJnaW5nLlxuICovXG5mdW5jdGlvbiBwcm9wc0VxdWFsKGE6IFNwYW5Qcm9wcywgYjogU3BhblByb3BzKTogYm9vbGVhbiB7XG4gIHJldHVybiAoXG4gICAgYS5jb2xvciA9PT0gYi5jb2xvciAmJlxuICAgIGEuYmFja2dyb3VuZENvbG9yID09PSBiLmJhY2tncm91bmRDb2xvciAmJlxuICAgIGEuYm9sZCA9PT0gYi5ib2xkICYmXG4gICAgYS5kaW0gPT09IGIuZGltICYmXG4gICAgYS5pdGFsaWMgPT09IGIuaXRhbGljICYmXG4gICAgYS51bmRlcmxpbmUgPT09IGIudW5kZXJsaW5lICYmXG4gICAgYS5zdHJpa2V0aHJvdWdoID09PSBiLnN0cmlrZXRocm91Z2ggJiZcbiAgICBhLmludmVyc2UgPT09IGIuaW52ZXJzZSAmJlxuICAgIGEuaHlwZXJsaW5rID09PSBiLmh5cGVybGlua1xuICApXG59XG5cbmZ1bmN0aW9uIGhhc0FueVByb3BzKHByb3BzOiBTcGFuUHJvcHMpOiBib29sZWFuIHtcbiAgcmV0dXJuIChcbiAgICBwcm9wcy5jb2xvciAhPT0gdW5kZWZpbmVkIHx8XG4gICAgcHJvcHMuYmFja2dyb3VuZENvbG9yICE9PSB1bmRlZmluZWQgfHxcbiAgICBwcm9wcy5kaW0gPT09IHRydWUgfHxcbiAgICBwcm9wcy5ib2xkID09PSB0cnVlIHx8XG4gICAgcHJvcHMuaXRhbGljID09PSB0cnVlIHx8XG4gICAgcHJvcHMudW5kZXJsaW5lID09PSB0cnVlIHx8XG4gICAgcHJvcHMuc3RyaWtldGhyb3VnaCA9PT0gdHJ1ZSB8fFxuICAgIHByb3BzLmludmVyc2UgPT09IHRydWUgfHxcbiAgICBwcm9wcy5oeXBlcmxpbmsgIT09IHVuZGVmaW5lZFxuICApXG59XG5cbmZ1bmN0aW9uIGhhc0FueVRleHRQcm9wcyhwcm9wczogU3BhblByb3BzKTogYm9vbGVhbiB7XG4gIHJldHVybiAoXG4gICAgcHJvcHMuY29sb3IgIT09IHVuZGVmaW5lZCB8fFxuICAgIHByb3BzLmJhY2tncm91bmRDb2xvciAhPT0gdW5kZWZpbmVkIHx8XG4gICAgcHJvcHMuZGltID09PSB0cnVlIHx8XG4gICAgcHJvcHMuYm9sZCA9PT0gdHJ1ZSB8fFxuICAgIHByb3BzLml0YWxpYyA9PT0gdHJ1ZSB8fFxuICAgIHByb3BzLnVuZGVybGluZSA9PT0gdHJ1ZSB8fFxuICAgIHByb3BzLnN0cmlrZXRocm91Z2ggPT09IHRydWUgfHxcbiAgICBwcm9wcy5pbnZlcnNlID09PSB0cnVlXG4gIClcbn1cblxuLy8gVGV4dCBzdHlsZSBwcm9wcyB3aXRob3V0IHdlaWdodCAoYm9sZC9kaW0pIC0gdGhlc2UgYXJlIGhhbmRsZWQgc2VwYXJhdGVseVxudHlwZSBCYXNlVGV4dFN0eWxlUHJvcHMgPSB7XG4gIGNvbG9yPzogQ29sb3JcbiAgYmFja2dyb3VuZENvbG9yPzogQ29sb3JcbiAgaXRhbGljPzogYm9vbGVhblxuICB1bmRlcmxpbmU/OiBib29sZWFuXG4gIHN0cmlrZXRocm91Z2g/OiBib29sZWFuXG4gIGludmVyc2U/OiBib29sZWFuXG59XG5cbi8vIFdyYXBwZXIgY29tcG9uZW50IHRoYXQgaGFuZGxlcyBib2xkL2RpbSBtdXR1YWwgZXhjbHVzaXZpdHkgZm9yIFRleHRcbmZ1bmN0aW9uIFN0eWxlZFRleHQoe1xuICBib2xkLFxuICBkaW0sXG4gIGNoaWxkcmVuLFxuICAuLi5yZXN0XG59OiBCYXNlVGV4dFN0eWxlUHJvcHMgJiB7XG4gIGJvbGQ/OiBib29sZWFuXG4gIGRpbT86IGJvb2xlYW5cbiAgY2hpbGRyZW46IHN0cmluZ1xufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIC8vIGRpbSB0YWtlcyBwcmVjZWRlbmNlIG92ZXIgYm9sZCB3aGVuIGJvdGggYXJlIHNldCAodGVybWluYWxzIHRyZWF0IHRoZW0gYXMgbXV0dWFsbHkgZXhjbHVzaXZlKVxuICBpZiAoZGltKSB7XG4gICAgcmV0dXJuIChcbiAgICAgIDxUZXh0IHsuLi5yZXN0fSBkaW0+XG4gICAgICAgIHtjaGlsZHJlbn1cbiAgICAgIDwvVGV4dD5cbiAgICApXG4gIH1cbiAgaWYgKGJvbGQpIHtcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQgey4uLnJlc3R9IGJvbGQ+XG4gICAgICAgIHtjaGlsZHJlbn1cbiAgICAgIDwvVGV4dD5cbiAgICApXG4gIH1cbiAgcmV0dXJuIDxUZXh0IHsuLi5yZXN0fT57Y2hpbGRyZW59PC9UZXh0PlxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsT0FBT0EsS0FBSyxNQUFNLE9BQU87QUFDekIsT0FBT0MsSUFBSSxNQUFNLHNCQUFzQjtBQUN2QyxPQUFPQyxJQUFJLE1BQU0sc0JBQXNCO0FBQ3ZDLGNBQWNDLEtBQUssUUFBUSxhQUFhO0FBQ3hDLFNBQ0UsS0FBS0MsVUFBVSxFQUNmQyxNQUFNLEVBQ04sS0FBS0YsS0FBSyxJQUFJRyxXQUFXLEVBQ3pCLEtBQUtDLFNBQVMsUUFDVCxhQUFhO0FBRXBCLEtBQUtDLEtBQUssR0FBRztFQUNYQyxRQUFRLEVBQUUsTUFBTTtFQUNoQjtFQUNBQyxRQUFRLENBQUMsRUFBRSxPQUFPO0FBQ3BCLENBQUM7QUFFRCxLQUFLQyxTQUFTLEdBQUc7RUFDZkMsS0FBSyxDQUFDLEVBQUVULEtBQUs7RUFDYlUsZUFBZSxDQUFDLEVBQUVWLEtBQUs7RUFDdkJXLEdBQUcsQ0FBQyxFQUFFLE9BQU87RUFDYkMsSUFBSSxDQUFDLEVBQUUsT0FBTztFQUNkQyxNQUFNLENBQUMsRUFBRSxPQUFPO0VBQ2hCQyxTQUFTLENBQUMsRUFBRSxPQUFPO0VBQ25CQyxhQUFhLENBQUMsRUFBRSxPQUFPO0VBQ3ZCQyxPQUFPLENBQUMsRUFBRSxPQUFPO0VBQ2pCQyxTQUFTLENBQUMsRUFBRSxNQUFNO0FBQ3BCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8sTUFBTUMsSUFBSSxHQUFHckIsS0FBSyxDQUFDc0IsSUFBSSxDQUFDLFNBQUFELEtBQUFFLEVBQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBYztJQUFBaEIsUUFBQTtJQUFBQztFQUFBLElBQUFhLEVBR3JDO0VBQ04sSUFBSSxPQUFPZCxRQUFRLEtBQUssUUFBUTtJQUFBLElBQUFpQixFQUFBO0lBQUEsSUFBQUYsQ0FBQSxRQUFBZixRQUFBLElBQUFlLENBQUEsUUFBQWQsUUFBQTtNQUN2QmdCLEVBQUEsR0FBQWhCLFFBQVEsR0FDYixDQUFDLElBQUksQ0FBQyxHQUFHLENBQUgsS0FBRSxDQUFDLENBQUUsQ0FBQWlCLE1BQU0sQ0FBQ2xCLFFBQVEsRUFBRSxFQUEzQixJQUFJLENBR04sR0FEQyxDQUFDLElBQUksQ0FBRSxDQUFBa0IsTUFBTSxDQUFDbEIsUUFBUSxFQUFFLEVBQXZCLElBQUksQ0FDTjtNQUFBZSxDQUFBLE1BQUFmLFFBQUE7TUFBQWUsQ0FBQSxNQUFBZCxRQUFBO01BQUFjLENBQUEsTUFBQUUsRUFBQTtJQUFBO01BQUFBLEVBQUEsR0FBQUYsQ0FBQTtJQUFBO0lBQUEsT0FKTUUsRUFJTjtFQUFBO0VBR0gsSUFBSWpCLFFBQVEsS0FBSyxFQUFFO0lBQUEsT0FDVixJQUFJO0VBQUE7RUFDWixJQUFBaUIsRUFBQTtFQUFBLElBQUFFLEVBQUE7RUFBQSxJQUFBSixDQUFBLFFBQUFmLFFBQUEsSUFBQWUsQ0FBQSxRQUFBZCxRQUFBO0lBS1FrQixFQUFBLEdBQUFDLE1BQUksQ0FBQUMsR0FBQSxDQUFKLDZCQUFHLENBQUM7SUFBQUMsR0FBQTtNQUhiLE1BQUFDLEtBQUEsR0FBY0MsWUFBWSxDQUFDeEIsUUFBUSxDQUFDO01BRXBDLElBQUl1QixLQUFLLENBQUFFLE1BQU8sS0FBSyxDQUFDO1FBQ2JOLEVBQUEsT0FBSTtRQUFKLE1BQUFHLEdBQUE7TUFBSTtNQUdiLElBQUlDLEtBQUssQ0FBQUUsTUFBTyxLQUFLLENBQWtDLElBQW5ELENBQXVCQyxXQUFXLENBQUNILEtBQUssR0FBRyxDQUFBSSxLQUFPLENBQUM7UUFDOUNSLEVBQUEsR0FBQWxCLFFBQVEsR0FDYixDQUFDLElBQUksQ0FBQyxHQUFHLENBQUgsS0FBRSxDQUFDLENBQUUsQ0FBQXNCLEtBQUssR0FBRyxDQUFBSyxJQUFLLENBQUUsRUFBekIsSUFBSSxDQUdOLEdBREMsQ0FBQyxJQUFJLENBQUUsQ0FBQUwsS0FBSyxHQUFHLENBQUFLLElBQUssQ0FBRSxFQUFyQixJQUFJLENBQ047UUFKTSxNQUFBTixHQUFBO01BSU47TUFDRixJQUFBTyxFQUFBO01BQUEsSUFBQWQsQ0FBQSxRQUFBZCxRQUFBO1FBRXlCNEIsRUFBQSxHQUFBQSxDQUFBQyxJQUFBLEVBQUFDLENBQUE7VUFDeEIsTUFBQXBCLFNBQUEsR0FBa0JtQixJQUFJLENBQUFILEtBQU0sQ0FBQWhCLFNBQVU7VUFFdEMsSUFBSVYsUUFBUTtZQUNWNkIsSUFBSSxDQUFBSCxLQUFNLENBQUF0QixHQUFBLEdBQU8sSUFBSDtVQUFBO1VBRWhCLE1BQUEyQixZQUFBLEdBQXFCQyxlQUFlLENBQUNILElBQUksQ0FBQUgsS0FBTSxDQUFDO1VBRWhELElBQUloQixTQUFTO1lBQUEsT0FDSnFCLFlBQVksR0FDakIsQ0FBQyxJQUFJLENBQU1ELEdBQUMsQ0FBREEsRUFBQSxDQUFDLENBQU9wQixHQUFTLENBQVRBLFVBQVEsQ0FBQyxDQUMxQixDQUFDLFVBQVUsQ0FDRixLQUFnQixDQUFoQixDQUFBbUIsSUFBSSxDQUFBSCxLQUFNLENBQUF4QixLQUFLLENBQUMsQ0FDTixlQUEwQixDQUExQixDQUFBMkIsSUFBSSxDQUFBSCxLQUFNLENBQUF2QixlQUFlLENBQUMsQ0FDdEMsR0FBYyxDQUFkLENBQUEwQixJQUFJLENBQUFILEtBQU0sQ0FBQXRCLEdBQUcsQ0FBQyxDQUNiLElBQWUsQ0FBZixDQUFBeUIsSUFBSSxDQUFBSCxLQUFNLENBQUFyQixJQUFJLENBQUMsQ0FDYixNQUFpQixDQUFqQixDQUFBd0IsSUFBSSxDQUFBSCxLQUFNLENBQUFwQixNQUFNLENBQUMsQ0FDZCxTQUFvQixDQUFwQixDQUFBdUIsSUFBSSxDQUFBSCxLQUFNLENBQUFuQixTQUFTLENBQUMsQ0FDaEIsYUFBd0IsQ0FBeEIsQ0FBQXNCLElBQUksQ0FBQUgsS0FBTSxDQUFBbEIsYUFBYSxDQUFDLENBQzlCLE9BQWtCLENBQWxCLENBQUFxQixJQUFJLENBQUFILEtBQU0sQ0FBQWpCLE9BQU8sQ0FBQyxDQUUxQixDQUFBb0IsSUFBSSxDQUFBRixJQUFJLENBQ1gsRUFYQyxVQUFVLENBWWIsRUFiQyxJQUFJLENBa0JOLEdBSEMsQ0FBQyxJQUFJLENBQU1HLEdBQUMsQ0FBREEsRUFBQSxDQUFDLENBQU9wQixHQUFTLENBQVRBLFVBQVEsQ0FBQyxDQUN6QixDQUFBbUIsSUFBSSxDQUFBRixJQUFJLENBQ1gsRUFGQyxJQUFJLENBR047VUFBQTtVQUNGLE9BRU1JLFlBQVksR0FDakIsQ0FBQyxVQUFVLENBQ0pELEdBQUMsQ0FBREEsRUFBQSxDQUFDLENBQ0MsS0FBZ0IsQ0FBaEIsQ0FBQUQsSUFBSSxDQUFBSCxLQUFNLENBQUF4QixLQUFLLENBQUMsQ0FDTixlQUEwQixDQUExQixDQUFBMkIsSUFBSSxDQUFBSCxLQUFNLENBQUF2QixlQUFlLENBQUMsQ0FDdEMsR0FBYyxDQUFkLENBQUEwQixJQUFJLENBQUFILEtBQU0sQ0FBQXRCLEdBQUcsQ0FBQyxDQUNiLElBQWUsQ0FBZixDQUFBeUIsSUFBSSxDQUFBSCxLQUFNLENBQUFyQixJQUFJLENBQUMsQ0FDYixNQUFpQixDQUFqQixDQUFBd0IsSUFBSSxDQUFBSCxLQUFNLENBQUFwQixNQUFNLENBQUMsQ0FDZCxTQUFvQixDQUFwQixDQUFBdUIsSUFBSSxDQUFBSCxLQUFNLENBQUFuQixTQUFTLENBQUMsQ0FDaEIsYUFBd0IsQ0FBeEIsQ0FBQXNCLElBQUksQ0FBQUgsS0FBTSxDQUFBbEIsYUFBYSxDQUFDLENBQzlCLE9BQWtCLENBQWxCLENBQUFxQixJQUFJLENBQUFILEtBQU0sQ0FBQWpCLE9BQU8sQ0FBQyxDQUUxQixDQUFBb0IsSUFBSSxDQUFBRixJQUFJLENBQ1gsRUFaQyxVQUFVLENBZVosR0FEQ0UsSUFBSSxDQUFBRixJQUNMO1FBQUEsQ0FDRjtRQUFBYixDQUFBLE1BQUFkLFFBQUE7UUFBQWMsQ0FBQSxNQUFBYyxFQUFBO01BQUE7UUFBQUEsRUFBQSxHQUFBZCxDQUFBO01BQUE7TUFoRGVFLEVBQUEsR0FBQU0sS0FBSyxDQUFBVyxHQUFJLENBQUNMLEVBZ0R6QixDQUFDO0lBQUE7SUFBQWQsQ0FBQSxNQUFBZixRQUFBO0lBQUFlLENBQUEsTUFBQWQsUUFBQTtJQUFBYyxDQUFBLE1BQUFFLEVBQUE7SUFBQUYsQ0FBQSxNQUFBSSxFQUFBO0VBQUE7SUFBQUYsRUFBQSxHQUFBRixDQUFBO0lBQUFJLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsSUFBQUksRUFBQSxLQUFBQyxNQUFBLENBQUFDLEdBQUE7SUFBQSxPQUFBRixFQUFBO0VBQUE7RUFoREYsTUFBQWdCLE9BQUEsR0FBZ0JsQixFQWdEZDtFQUFBLElBQUFZLEVBQUE7RUFBQSxJQUFBZCxDQUFBLFFBQUFvQixPQUFBLElBQUFwQixDQUFBLFNBQUFkLFFBQUE7SUFFSzRCLEVBQUEsR0FBQTVCLFFBQVEsR0FBRyxDQUFDLElBQUksQ0FBQyxHQUFHLENBQUgsS0FBRSxDQUFDLENBQUVrQyxRQUFNLENBQUUsRUFBbEIsSUFBSSxDQUE4QyxHQUF0QixDQUFDLElBQUksQ0FBRUEsUUFBTSxDQUFFLEVBQWQsSUFBSSxDQUFpQjtJQUFBcEIsQ0FBQSxNQUFBb0IsT0FBQTtJQUFBcEIsQ0FBQSxPQUFBZCxRQUFBO0lBQUFjLENBQUEsT0FBQWMsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQWQsQ0FBQTtFQUFBO0VBQUEsT0FBOURjLEVBQThEO0FBQUEsQ0FDdEUsQ0FBQztBQUVGLEtBQUtPLElBQUksR0FBRztFQUNWUixJQUFJLEVBQUUsTUFBTTtFQUNaRCxLQUFLLEVBQUV6QixTQUFTO0FBQ2xCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0EsU0FBU3NCLFlBQVlBLENBQUNhLEtBQUssRUFBRSxNQUFNLENBQUMsRUFBRUQsSUFBSSxFQUFFLENBQUM7RUFDM0MsTUFBTUUsTUFBTSxHQUFHLElBQUkxQyxNQUFNLENBQUMsQ0FBQztFQUMzQixNQUFNMkMsT0FBTyxHQUFHRCxNQUFNLENBQUNFLElBQUksQ0FBQ0gsS0FBSyxDQUFDO0VBQ2xDLE1BQU1kLEtBQUssRUFBRWEsSUFBSSxFQUFFLEdBQUcsRUFBRTtFQUV4QixJQUFJSyxnQkFBZ0IsRUFBRSxNQUFNLEdBQUcsU0FBUztFQUV4QyxLQUFLLE1BQU1DLE1BQU0sSUFBSUgsT0FBTyxFQUFFO0lBQzVCLElBQUlHLE1BQU0sQ0FBQ0MsSUFBSSxLQUFLLE1BQU0sRUFBRTtNQUMxQixJQUFJRCxNQUFNLENBQUNBLE1BQU0sQ0FBQ0MsSUFBSSxLQUFLLE9BQU8sRUFBRTtRQUNsQ0YsZ0JBQWdCLEdBQUdDLE1BQU0sQ0FBQ0EsTUFBTSxDQUFDRSxHQUFHO01BQ3RDLENBQUMsTUFBTTtRQUNMSCxnQkFBZ0IsR0FBR0ksU0FBUztNQUM5QjtNQUNBO0lBQ0Y7SUFFQSxJQUFJSCxNQUFNLENBQUNDLElBQUksS0FBSyxNQUFNLEVBQUU7TUFDMUIsTUFBTWYsSUFBSSxHQUFHYyxNQUFNLENBQUNJLFNBQVMsQ0FBQ1osR0FBRyxDQUFDYSxDQUFDLElBQUlBLENBQUMsQ0FBQ0MsS0FBSyxDQUFDLENBQUNDLElBQUksQ0FBQyxFQUFFLENBQUM7TUFDeEQsSUFBSSxDQUFDckIsSUFBSSxFQUFFO01BRVgsTUFBTUQsS0FBSyxHQUFHdUIsb0JBQW9CLENBQUNSLE1BQU0sQ0FBQ1MsS0FBSyxDQUFDO01BQ2hELElBQUlWLGdCQUFnQixFQUFFO1FBQ3BCZCxLQUFLLENBQUNoQixTQUFTLEdBQUc4QixnQkFBZ0I7TUFDcEM7O01BRUE7TUFDQSxNQUFNVyxRQUFRLEdBQUc3QixLQUFLLENBQUNBLEtBQUssQ0FBQ0UsTUFBTSxHQUFHLENBQUMsQ0FBQztNQUN4QyxJQUFJMkIsUUFBUSxJQUFJQyxVQUFVLENBQUNELFFBQVEsQ0FBQ3pCLEtBQUssRUFBRUEsS0FBSyxDQUFDLEVBQUU7UUFDakR5QixRQUFRLENBQUN4QixJQUFJLElBQUlBLElBQUk7TUFDdkIsQ0FBQyxNQUFNO1FBQ0xMLEtBQUssQ0FBQytCLElBQUksQ0FBQztVQUFFMUIsSUFBSTtVQUFFRDtRQUFNLENBQUMsQ0FBQztNQUM3QjtJQUNGO0VBQ0Y7RUFFQSxPQUFPSixLQUFLO0FBQ2Q7O0FBRUE7QUFDQTtBQUNBO0FBQ0EsU0FBUzJCLG9CQUFvQkEsQ0FBQ0MsS0FBSyxFQUFFckQsU0FBUyxDQUFDLEVBQUVJLFNBQVMsQ0FBQztFQUN6RCxNQUFNeUIsS0FBSyxFQUFFekIsU0FBUyxHQUFHLENBQUMsQ0FBQztFQUUzQixJQUFJaUQsS0FBSyxDQUFDN0MsSUFBSSxFQUFFcUIsS0FBSyxDQUFDckIsSUFBSSxHQUFHLElBQUk7RUFDakMsSUFBSTZDLEtBQUssQ0FBQzlDLEdBQUcsRUFBRXNCLEtBQUssQ0FBQ3RCLEdBQUcsR0FBRyxJQUFJO0VBQy9CLElBQUk4QyxLQUFLLENBQUM1QyxNQUFNLEVBQUVvQixLQUFLLENBQUNwQixNQUFNLEdBQUcsSUFBSTtFQUNyQyxJQUFJNEMsS0FBSyxDQUFDM0MsU0FBUyxLQUFLLE1BQU0sRUFBRW1CLEtBQUssQ0FBQ25CLFNBQVMsR0FBRyxJQUFJO0VBQ3RELElBQUkyQyxLQUFLLENBQUMxQyxhQUFhLEVBQUVrQixLQUFLLENBQUNsQixhQUFhLEdBQUcsSUFBSTtFQUNuRCxJQUFJMEMsS0FBSyxDQUFDekMsT0FBTyxFQUFFaUIsS0FBSyxDQUFDakIsT0FBTyxHQUFHLElBQUk7RUFFdkMsTUFBTTZDLE9BQU8sR0FBR0MsYUFBYSxDQUFDTCxLQUFLLENBQUNNLEVBQUUsQ0FBQztFQUN2QyxJQUFJRixPQUFPLEVBQUU1QixLQUFLLENBQUN4QixLQUFLLEdBQUdvRCxPQUFPO0VBRWxDLE1BQU1HLE9BQU8sR0FBR0YsYUFBYSxDQUFDTCxLQUFLLENBQUNRLEVBQUUsQ0FBQztFQUN2QyxJQUFJRCxPQUFPLEVBQUUvQixLQUFLLENBQUN2QixlQUFlLEdBQUdzRCxPQUFPO0VBRTVDLE9BQU8vQixLQUFLO0FBQ2Q7O0FBRUE7QUFDQSxNQUFNaUMsZUFBZSxFQUFFQyxNQUFNLENBQUNsRSxVQUFVLEVBQUUsTUFBTSxDQUFDLEdBQUc7RUFDbERtRSxLQUFLLEVBQUUsWUFBWTtFQUNuQkMsR0FBRyxFQUFFLFVBQVU7RUFDZkMsS0FBSyxFQUFFLFlBQVk7RUFDbkJDLE1BQU0sRUFBRSxhQUFhO0VBQ3JCQyxJQUFJLEVBQUUsV0FBVztFQUNqQkMsT0FBTyxFQUFFLGNBQWM7RUFDdkJDLElBQUksRUFBRSxXQUFXO0VBQ2pCQyxLQUFLLEVBQUUsWUFBWTtFQUNuQkMsV0FBVyxFQUFFLGtCQUFrQjtFQUMvQkMsU0FBUyxFQUFFLGdCQUFnQjtFQUMzQkMsV0FBVyxFQUFFLGtCQUFrQjtFQUMvQkMsWUFBWSxFQUFFLG1CQUFtQjtFQUNqQ0MsVUFBVSxFQUFFLGlCQUFpQjtFQUM3QkMsYUFBYSxFQUFFLG9CQUFvQjtFQUNuQ0MsVUFBVSxFQUFFLGlCQUFpQjtFQUM3QkMsV0FBVyxFQUFFO0FBQ2YsQ0FBQzs7QUFFRDtBQUNBO0FBQ0E7QUFDQSxTQUFTckIsYUFBYUEsQ0FBQ3JELEtBQUssRUFBRU4sV0FBVyxDQUFDLEVBQUVILEtBQUssR0FBRyxTQUFTLENBQUM7RUFDNUQsUUFBUVMsS0FBSyxDQUFDd0MsSUFBSTtJQUNoQixLQUFLLE9BQU87TUFDVixPQUFPaUIsZUFBZSxDQUFDekQsS0FBSyxDQUFDMkUsSUFBSSxDQUFDLElBQUlwRixLQUFLO0lBQzdDLEtBQUssU0FBUztNQUNaLE9BQU8sV0FBV1MsS0FBSyxDQUFDNEUsS0FBSyxHQUFHLElBQUlyRixLQUFLO0lBQzNDLEtBQUssS0FBSztNQUNSLE9BQU8sT0FBT1MsS0FBSyxDQUFDNkUsQ0FBQyxJQUFJN0UsS0FBSyxDQUFDNEMsQ0FBQyxJQUFJNUMsS0FBSyxDQUFDOEUsQ0FBQyxHQUFHLElBQUl2RixLQUFLO0lBQ3pELEtBQUssU0FBUztNQUNaLE9BQU9tRCxTQUFTO0VBQ3BCO0FBQ0Y7O0FBRUE7QUFDQTtBQUNBO0FBQ0EsU0FBU1EsVUFBVUEsQ0FBQzZCLENBQUMsRUFBRWhGLFNBQVMsRUFBRStFLENBQUMsRUFBRS9FLFNBQVMsQ0FBQyxFQUFFLE9BQU8sQ0FBQztFQUN2RCxPQUNFZ0YsQ0FBQyxDQUFDL0UsS0FBSyxLQUFLOEUsQ0FBQyxDQUFDOUUsS0FBSyxJQUNuQitFLENBQUMsQ0FBQzlFLGVBQWUsS0FBSzZFLENBQUMsQ0FBQzdFLGVBQWUsSUFDdkM4RSxDQUFDLENBQUM1RSxJQUFJLEtBQUsyRSxDQUFDLENBQUMzRSxJQUFJLElBQ2pCNEUsQ0FBQyxDQUFDN0UsR0FBRyxLQUFLNEUsQ0FBQyxDQUFDNUUsR0FBRyxJQUNmNkUsQ0FBQyxDQUFDM0UsTUFBTSxLQUFLMEUsQ0FBQyxDQUFDMUUsTUFBTSxJQUNyQjJFLENBQUMsQ0FBQzFFLFNBQVMsS0FBS3lFLENBQUMsQ0FBQ3pFLFNBQVMsSUFDM0IwRSxDQUFDLENBQUN6RSxhQUFhLEtBQUt3RSxDQUFDLENBQUN4RSxhQUFhLElBQ25DeUUsQ0FBQyxDQUFDeEUsT0FBTyxLQUFLdUUsQ0FBQyxDQUFDdkUsT0FBTyxJQUN2QndFLENBQUMsQ0FBQ3ZFLFNBQVMsS0FBS3NFLENBQUMsQ0FBQ3RFLFNBQVM7QUFFL0I7QUFFQSxTQUFTZSxXQUFXQSxDQUFDQyxLQUFLLEVBQUV6QixTQUFTLENBQUMsRUFBRSxPQUFPLENBQUM7RUFDOUMsT0FDRXlCLEtBQUssQ0FBQ3hCLEtBQUssS0FBSzBDLFNBQVMsSUFDekJsQixLQUFLLENBQUN2QixlQUFlLEtBQUt5QyxTQUFTLElBQ25DbEIsS0FBSyxDQUFDdEIsR0FBRyxLQUFLLElBQUksSUFDbEJzQixLQUFLLENBQUNyQixJQUFJLEtBQUssSUFBSSxJQUNuQnFCLEtBQUssQ0FBQ3BCLE1BQU0sS0FBSyxJQUFJLElBQ3JCb0IsS0FBSyxDQUFDbkIsU0FBUyxLQUFLLElBQUksSUFDeEJtQixLQUFLLENBQUNsQixhQUFhLEtBQUssSUFBSSxJQUM1QmtCLEtBQUssQ0FBQ2pCLE9BQU8sS0FBSyxJQUFJLElBQ3RCaUIsS0FBSyxDQUFDaEIsU0FBUyxLQUFLa0MsU0FBUztBQUVqQztBQUVBLFNBQVNaLGVBQWVBLENBQUNOLEtBQUssRUFBRXpCLFNBQVMsQ0FBQyxFQUFFLE9BQU8sQ0FBQztFQUNsRCxPQUNFeUIsS0FBSyxDQUFDeEIsS0FBSyxLQUFLMEMsU0FBUyxJQUN6QmxCLEtBQUssQ0FBQ3ZCLGVBQWUsS0FBS3lDLFNBQVMsSUFDbkNsQixLQUFLLENBQUN0QixHQUFHLEtBQUssSUFBSSxJQUNsQnNCLEtBQUssQ0FBQ3JCLElBQUksS0FBSyxJQUFJLElBQ25CcUIsS0FBSyxDQUFDcEIsTUFBTSxLQUFLLElBQUksSUFDckJvQixLQUFLLENBQUNuQixTQUFTLEtBQUssSUFBSSxJQUN4Qm1CLEtBQUssQ0FBQ2xCLGFBQWEsS0FBSyxJQUFJLElBQzVCa0IsS0FBSyxDQUFDakIsT0FBTyxLQUFLLElBQUk7QUFFMUI7O0FBRUE7QUFDQSxLQUFLeUUsa0JBQWtCLEdBQUc7RUFDeEJoRixLQUFLLENBQUMsRUFBRVQsS0FBSztFQUNiVSxlQUFlLENBQUMsRUFBRVYsS0FBSztFQUN2QmEsTUFBTSxDQUFDLEVBQUUsT0FBTztFQUNoQkMsU0FBUyxDQUFDLEVBQUUsT0FBTztFQUNuQkMsYUFBYSxDQUFDLEVBQUUsT0FBTztFQUN2QkMsT0FBTyxDQUFDLEVBQUUsT0FBTztBQUNuQixDQUFDOztBQUVEO0FBQ0EsU0FBQTBFLFdBQUF0RSxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQUEsSUFBQVYsSUFBQTtFQUFBLElBQUFOLFFBQUE7RUFBQSxJQUFBSyxHQUFBO0VBQUEsSUFBQWdGLElBQUE7RUFBQSxJQUFBdEUsQ0FBQSxRQUFBRCxFQUFBO0lBQW9CO01BQUFSLElBQUE7TUFBQUQsR0FBQTtNQUFBTCxRQUFBO01BQUEsR0FBQXFGO0lBQUEsSUFBQXZFLEVBU25CO0lBQUFDLENBQUEsTUFBQUQsRUFBQTtJQUFBQyxDQUFBLE1BQUFULElBQUE7SUFBQVMsQ0FBQSxNQUFBZixRQUFBO0lBQUFlLENBQUEsTUFBQVYsR0FBQTtJQUFBVSxDQUFBLE1BQUFzRSxJQUFBO0VBQUE7SUFBQS9FLElBQUEsR0FBQVMsQ0FBQTtJQUFBZixRQUFBLEdBQUFlLENBQUE7SUFBQVYsR0FBQSxHQUFBVSxDQUFBO0lBQUFzRSxJQUFBLEdBQUF0RSxDQUFBO0VBQUE7RUFFQyxJQUFJVixHQUFHO0lBQUEsSUFBQVksRUFBQTtJQUFBLElBQUFGLENBQUEsUUFBQWYsUUFBQSxJQUFBZSxDQUFBLFFBQUFzRSxJQUFBO01BRUhwRSxFQUFBLElBQUMsSUFBSSxLQUFLb0UsSUFBSSxFQUFFLEdBQUcsQ0FBSCxLQUFFLENBQUMsQ0FDaEJyRixTQUFPLENBQ1YsRUFGQyxJQUFJLENBRUU7TUFBQWUsQ0FBQSxNQUFBZixRQUFBO01BQUFlLENBQUEsTUFBQXNFLElBQUE7TUFBQXRFLENBQUEsTUFBQUUsRUFBQTtJQUFBO01BQUFBLEVBQUEsR0FBQUYsQ0FBQTtJQUFBO0lBQUEsT0FGUEUsRUFFTztFQUFBO0VBR1gsSUFBSVgsSUFBSTtJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBRixDQUFBLFFBQUFmLFFBQUEsSUFBQWUsQ0FBQSxRQUFBc0UsSUFBQTtNQUVKcEUsRUFBQSxJQUFDLElBQUksS0FBS29FLElBQUksRUFBRSxJQUFJLENBQUosS0FBRyxDQUFDLENBQ2pCckYsU0FBTyxDQUNWLEVBRkMsSUFBSSxDQUVFO01BQUFlLENBQUEsTUFBQWYsUUFBQTtNQUFBZSxDQUFBLE1BQUFzRSxJQUFBO01BQUF0RSxDQUFBLE9BQUFFLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFGLENBQUE7SUFBQTtJQUFBLE9BRlBFLEVBRU87RUFBQTtFQUVWLElBQUFBLEVBQUE7RUFBQSxJQUFBRixDQUFBLFNBQUFmLFFBQUEsSUFBQWUsQ0FBQSxTQUFBc0UsSUFBQTtJQUNNcEUsRUFBQSxJQUFDLElBQUksS0FBS29FLElBQUksRUFBR3JGLFNBQU8sQ0FBRSxFQUF6QixJQUFJLENBQTRCO0lBQUFlLENBQUEsT0FBQWYsUUFBQTtJQUFBZSxDQUFBLE9BQUFzRSxJQUFBO0lBQUF0RSxDQUFBLE9BQUFFLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFGLENBQUE7RUFBQTtFQUFBLE9BQWpDRSxFQUFpQztBQUFBIiwiaWdub3JlTGlzdCI6W119 diff --git a/ui-tui/packages/hermes-ink/src/ink/bidi.ts b/ui-tui/packages/hermes-ink/src/ink/bidi.ts new file mode 100644 index 0000000000..28edace8a9 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/bidi.ts @@ -0,0 +1,145 @@ +/** + * Bidirectional text reordering for terminal rendering. + * + * Terminals on Windows do not implement the Unicode Bidi Algorithm, + * so RTL text (Hebrew, Arabic, etc.) appears reversed. This module + * applies the bidi algorithm to reorder ClusteredChar arrays from + * logical order to visual order before Ink's LTR cell placement loop. + * + * On macOS terminals (Terminal.app, iTerm2) bidi works natively. + * Windows Terminal (including WSL) does not implement bidi + * (https://github.com/microsoft/terminal/issues/538). + * + * Detection: Windows Terminal sets WT_SESSION; native Windows cmd/conhost + * also lacks bidi. We enable bidi reordering when running on Windows or + * inside Windows Terminal (covers WSL). + */ +import bidiFactory from 'bidi-js' + +type ClusteredChar = { + value: string + width: number + styleId: number + hyperlink: string | undefined +} + +let bidiInstance: ReturnType | undefined +let needsSoftwareBidi: boolean | undefined + +function needsBidi(): boolean { + if (needsSoftwareBidi === undefined) { + needsSoftwareBidi = + process.platform === 'win32' || + typeof process.env['WT_SESSION'] === 'string' || // WSL in Windows Terminal + process.env['TERM_PROGRAM'] === 'vscode' // VS Code integrated terminal (xterm.js) + } + + return needsSoftwareBidi +} + +function getBidi() { + if (!bidiInstance) { + bidiInstance = bidiFactory() + } + + return bidiInstance +} + +/** + * Reorder an array of ClusteredChars from logical order to visual order + * using the Unicode Bidi Algorithm. Active on terminals that lack native + * bidi support (Windows Terminal, conhost, WSL). + * + * Returns the same array on bidi-capable terminals (no-op). + */ +export function reorderBidi(characters: ClusteredChar[]): ClusteredChar[] { + if (!needsBidi() || characters.length === 0) { + return characters + } + + // Build a plain string from the clustered chars to run through bidi + const plainText = characters.map(c => c.value).join('') + + // Check if there are any RTL characters — skip bidi if pure LTR + if (!hasRTLCharacters(plainText)) { + return characters + } + + const bidi = getBidi() + const { levels } = bidi.getEmbeddingLevels(plainText, 'auto') + + // Map bidi levels back to ClusteredChar indices. + // Each ClusteredChar may be multiple code units in the joined string. + const charLevels: number[] = [] + let offset = 0 + + for (let i = 0; i < characters.length; i++) { + charLevels.push(levels[offset]!) + offset += characters[i]!.value.length + } + + // Get reorder segments from bidi-js, but we need to work at the + // ClusteredChar level, not the string level. We'll implement the + // standard bidi reordering: find the max level, then for each level + // from max down to 1, reverse all contiguous runs >= that level. + const reordered = [...characters] + const maxLevel = Math.max(...charLevels) + + for (let level = maxLevel; level >= 1; level--) { + let i = 0 + + while (i < reordered.length) { + if (charLevels[i]! >= level) { + // Find the end of this run + let j = i + 1 + + while (j < reordered.length && charLevels[j]! >= level) { + j++ + } + + // Reverse the run in both arrays + reverseRange(reordered, i, j - 1) + reverseRangeNumbers(charLevels, i, j - 1) + i = j + } else { + i++ + } + } + } + + return reordered +} + +function reverseRange(arr: T[], start: number, end: number): void { + while (start < end) { + const temp = arr[start]! + arr[start] = arr[end]! + arr[end] = temp + start++ + end-- + } +} + +function reverseRangeNumbers(arr: number[], start: number, end: number): void { + while (start < end) { + const temp = arr[start]! + arr[start] = arr[end]! + arr[end] = temp + start++ + end-- + } +} + +/** + * Quick check for RTL characters (Hebrew, Arabic, and related scripts). + * Avoids running the full bidi algorithm on pure-LTR text. + */ +function hasRTLCharacters(text: string): boolean { + // Hebrew: U+0590-U+05FF, U+FB1D-U+FB4F + // Arabic: U+0600-U+06FF, U+0750-U+077F, U+08A0-U+08FF, U+FB50-U+FDFF, U+FE70-U+FEFF + // Thaana: U+0780-U+07BF + // Syriac: U+0700-U+074F + return /[\u0590-\u05FF\uFB1D-\uFB4F\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u0780-\u07BF\u0700-\u074F]/u.test( + text + ) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts b/ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts new file mode 100644 index 0000000000..4ccaeeace0 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/clearTerminal.ts @@ -0,0 +1,68 @@ +/** + * Cross-platform terminal clearing with scrollback support. + * Detects modern terminals that support ESC[3J for clearing scrollback. + */ + +import { csi, CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK } from './termio/csi.js' + +// HVP (Horizontal Vertical Position) - legacy Windows cursor home +const CURSOR_HOME_WINDOWS = csi(0, 'f') + +function isWindowsTerminal(): boolean { + return process.platform === 'win32' && !!process.env.WT_SESSION +} + +function isMintty(): boolean { + // mintty 3.1.5+ sets TERM_PROGRAM to 'mintty' + if (process.env.TERM_PROGRAM === 'mintty') { + return true + } + + // GitBash/MSYS2/MINGW use mintty and set MSYSTEM + if (process.platform === 'win32' && process.env.MSYSTEM) { + return true + } + + return false +} + +function isModernWindowsTerminal(): boolean { + // Windows Terminal sets WT_SESSION environment variable + if (isWindowsTerminal()) { + return true + } + + // VS Code integrated terminal on Windows with ConPTY support + if (process.platform === 'win32' && process.env.TERM_PROGRAM === 'vscode' && process.env.TERM_PROGRAM_VERSION) { + return true + } + + // mintty (GitBash/MSYS2/Cygwin) supports modern escape sequences + if (isMintty()) { + return true + } + + return false +} + +/** + * Returns the ANSI escape sequence to clear the terminal including scrollback. + * Automatically detects terminal capabilities. + */ +export function getClearTerminalSequence(): string { + if (process.platform === 'win32') { + if (isModernWindowsTerminal()) { + return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME + } else { + // Legacy Windows console - can't clear scrollback + return ERASE_SCREEN + CURSOR_HOME_WINDOWS + } + } + + return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME +} + +/** + * Clears the terminal screen. On supported terminals, also clears scrollback. + */ +export const clearTerminal = getClearTerminalSequence() diff --git a/ui-tui/packages/hermes-ink/src/ink/colorize.ts b/ui-tui/packages/hermes-ink/src/ink/colorize.ts new file mode 100644 index 0000000000..ebc3159b78 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/colorize.ts @@ -0,0 +1,233 @@ +import chalk from 'chalk' + +import type { Color, TextStyles } from './styles.js' + +/** + * xterm.js (VS Code, Cursor, code-server, Coder) has supported truecolor + * since 2017, but code-server/Coder containers often don't set + * COLORTERM=truecolor. chalk's supports-color doesn't recognize + * TERM_PROGRAM=vscode (it only knows iTerm.app/Apple_Terminal), so it falls + * through to the -256color regex → level 2. At level 2, chalk.rgb() + * downgrades to the nearest 6×6×6 cube color: rgb(215,119,87) (Claude + * orange) → idx 174 rgb(215,135,135) — washed-out salmon. + * + * Gated on level === 2 (not < 3) to respect NO_COLOR / FORCE_COLOR=0 — + * those yield level 0 and are an explicit "no colors" request. Desktop VS + * Code sets COLORTERM=truecolor itself, so this is a no-op there (already 3). + * + * Must run BEFORE the tmux clamp — if tmux is running inside a VS Code + * terminal, tmux's passthrough limitation wins and we want level 2. + */ +function boostChalkLevelForXtermJs(): boolean { + if (process.env.TERM_PROGRAM === 'vscode' && chalk.level === 2) { + chalk.level = 3 + + return true + } + + return false +} + +/** + * tmux parses truecolor SGR (\e[48;2;r;g;bm) into its cell buffer correctly, + * but its client-side emitter only re-emits truecolor to the outer terminal if + * the outer terminal advertises Tc/RGB capability (via terminal-overrides). + * Default tmux config doesn't set this, so tmux emits the cell to iTerm2/etc + * WITHOUT the bg sequence — outer terminal's buffer has bg=default → black on + * dark profiles. Clamping to level 2 makes chalk emit 256-color (\e[48;5;Nm), + * which tmux passes through cleanly. grey93 (255) is visually identical to + * rgb(240,240,240). + * + * Users who HAVE set `terminal-overrides ,*:Tc` get a technically-unnecessary + * downgrade, but the visual difference is imperceptible. Querying + * `tmux show -gv terminal-overrides` to detect this would add a subprocess on + * startup — not worth it. + * + * $TMUX is a pty-lifecycle env var set by tmux itself; it never comes from + * globalSettings.env, so reading it here is correct. chalk is a singleton, so + * this clamps ALL truecolor output (fg+bg+hex) across the entire app. + */ +function clampChalkLevelForTmux(): boolean { + // bg.ts sets terminal-overrides :Tc before attach, so truecolor passes + // through — skip the clamp. General escape hatch for anyone who's + // configured their tmux correctly. + if (process.env.CLAUDE_CODE_TMUX_TRUECOLOR) { + return false + } + + if (process.env.TMUX && chalk.level > 2) { + chalk.level = 2 + + return true + } + + return false +} + +// Computed once at module load — terminal/tmux environment doesn't change mid-session. +// Order matters: boost first so the tmux clamp can re-clamp if tmux is running +// inside a VS Code terminal. Exported for debugging — tree-shaken if unused. +export const CHALK_BOOSTED_FOR_XTERMJS = boostChalkLevelForXtermJs() +export const CHALK_CLAMPED_FOR_TMUX = clampChalkLevelForTmux() + +export type ColorType = 'foreground' | 'background' + +const RGB_REGEX = /^rgb\(\s?(\d+),\s?(\d+),\s?(\d+)\s?\)$/ +const ANSI_REGEX = /^ansi256\(\s?(\d+)\s?\)$/ + +export const colorize = (str: string, color: string | undefined, type: ColorType): string => { + if (!color) { + return str + } + + if (color.startsWith('ansi:')) { + const value = color.substring('ansi:'.length) + + switch (value) { + case 'black': + return type === 'foreground' ? chalk.black(str) : chalk.bgBlack(str) + + case 'red': + return type === 'foreground' ? chalk.red(str) : chalk.bgRed(str) + + case 'green': + return type === 'foreground' ? chalk.green(str) : chalk.bgGreen(str) + + case 'yellow': + return type === 'foreground' ? chalk.yellow(str) : chalk.bgYellow(str) + + case 'blue': + return type === 'foreground' ? chalk.blue(str) : chalk.bgBlue(str) + + case 'magenta': + return type === 'foreground' ? chalk.magenta(str) : chalk.bgMagenta(str) + + case 'cyan': + return type === 'foreground' ? chalk.cyan(str) : chalk.bgCyan(str) + + case 'white': + return type === 'foreground' ? chalk.white(str) : chalk.bgWhite(str) + + case 'blackBright': + return type === 'foreground' ? chalk.blackBright(str) : chalk.bgBlackBright(str) + + case 'redBright': + return type === 'foreground' ? chalk.redBright(str) : chalk.bgRedBright(str) + + case 'greenBright': + return type === 'foreground' ? chalk.greenBright(str) : chalk.bgGreenBright(str) + + case 'yellowBright': + return type === 'foreground' ? chalk.yellowBright(str) : chalk.bgYellowBright(str) + + case 'blueBright': + return type === 'foreground' ? chalk.blueBright(str) : chalk.bgBlueBright(str) + + case 'magentaBright': + return type === 'foreground' ? chalk.magentaBright(str) : chalk.bgMagentaBright(str) + + case 'cyanBright': + return type === 'foreground' ? chalk.cyanBright(str) : chalk.bgCyanBright(str) + + case 'whiteBright': + return type === 'foreground' ? chalk.whiteBright(str) : chalk.bgWhiteBright(str) + } + } + + if (color.startsWith('#')) { + return type === 'foreground' ? chalk.hex(color)(str) : chalk.bgHex(color)(str) + } + + if (color.startsWith('ansi256')) { + const matches = ANSI_REGEX.exec(color) + + if (!matches) { + return str + } + + const value = Number(matches[1]) + + return type === 'foreground' ? chalk.ansi256(value)(str) : chalk.bgAnsi256(value)(str) + } + + if (color.startsWith('rgb')) { + const matches = RGB_REGEX.exec(color) + + if (!matches) { + return str + } + + const firstValue = Number(matches[1]) + const secondValue = Number(matches[2]) + const thirdValue = Number(matches[3]) + + return type === 'foreground' + ? chalk.rgb(firstValue, secondValue, thirdValue)(str) + : chalk.bgRgb(firstValue, secondValue, thirdValue)(str) + } + + return str +} + +/** + * Apply TextStyles to a string using chalk. + * This is the inverse of parsing ANSI codes - we generate them from structured styles. + * Theme resolution happens at component layer, not here. + */ +export function applyTextStyles(text: string, styles: TextStyles): string { + let result = text + + // Apply styles in reverse order of desired nesting. + // chalk wraps text so later calls become outer wrappers. + // Desired order (outermost to innermost): + // background > foreground > text modifiers + // So we apply: text modifiers first, then foreground, then background last. + + if (styles.inverse) { + result = chalk.inverse(result) + } + + if (styles.strikethrough) { + result = chalk.strikethrough(result) + } + + if (styles.underline) { + result = chalk.underline(result) + } + + if (styles.italic) { + result = chalk.italic(result) + } + + if (styles.bold) { + result = chalk.bold(result) + } + + if (styles.dim) { + result = chalk.dim(result) + } + + if (styles.color) { + // Color is now always a raw color value (theme resolution happens at component layer) + result = colorize(result, styles.color, 'foreground') + } + + if (styles.backgroundColor) { + // backgroundColor is now always a raw color value + result = colorize(result, styles.backgroundColor, 'background') + } + + return result +} + +/** + * Apply a raw color value to text. + * Theme resolution should happen at component layer, not here. + */ +export function applyColor(text: string, color: Color | undefined): string { + if (!color) { + return text + } + + return colorize(text, color, 'foreground') +} diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx new file mode 100644 index 0000000000..757f7789b8 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -0,0 +1,93 @@ +import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react' +import { c as _c } from 'react/compiler-runtime' + +import instances from '../instances.js' +import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js' +import { TerminalWriteContext } from '../useTerminalNotification.js' + +import Box from './Box.js' +import { TerminalSizeContext } from './TerminalSizeContext.js' +type Props = PropsWithChildren<{ + /** Enable SGR mouse tracking (wheel + click/drag). Default true. */ + mouseTracking?: boolean +}> + +/** + * Run children in the terminal's alternate screen buffer, constrained to + * the viewport height. While mounted: + * + * - Enters the alt screen (DEC 1049), clears it, homes the cursor + * - Constrains its own height to the terminal row count, so overflow must + * be handled via `overflow: scroll` / flexbox (no native scrollback) + * - Optionally enables SGR mouse tracking (wheel + click/drag) — events + * surface as `ParsedKey` (wheel) and update the Ink instance's + * selection state (click/drag) + * + * On unmount, disables mouse tracking and exits the alt screen, restoring + * the main screen's content. Safe for use in ctrl-o transcript overlays + * and similar temporary fullscreen views — the main screen is preserved. + * + * Notifies the Ink instance via `setAltScreenActive()` so the renderer + * keeps the cursor inside the viewport (preventing the cursor-restore LF + * from scrolling content) and so signal-exit cleanup can exit the alt + * screen if the component's own unmount doesn't run. + */ +export function AlternateScreen(t0) { + const $ = _c(7) + + const { children, mouseTracking: t1 } = t0 + + const mouseTracking = t1 === undefined ? true : t1 + const size = useContext(TerminalSizeContext) + const writeRaw = useContext(TerminalWriteContext) + let t2 + let t3 + + if ($[0] !== mouseTracking || $[1] !== writeRaw) { + t2 = () => { + const ink = instances.get(process.stdout) + + if (!writeRaw) { + return + } + + writeRaw(ENTER_ALT_SCREEN + '\x1B[2J\x1B[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')) + ink?.setAltScreenActive(true, mouseTracking) + + return () => { + ink?.setAltScreenActive(false) + ink?.clearTextSelection() + writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN) + } + } + + t3 = [writeRaw, mouseTracking] + $[0] = mouseTracking + $[1] = writeRaw + $[2] = t2 + $[3] = t3 + } else { + t2 = $[2] + t3 = $[3] + } + + useInsertionEffect(t2, t3) + const t4 = size?.rows ?? 24 + let t5 + + if ($[4] !== children || $[5] !== t4) { + t5 = ( + + {children} + + ) + $[4] = children + $[5] = t4 + $[6] = t5 + } else { + t5 = $[6] + } + + return t5 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwidXNlQ29udGV4dCIsInVzZUluc2VydGlvbkVmZmVjdCIsImluc3RhbmNlcyIsIkRJU0FCTEVfTU9VU0VfVFJBQ0tJTkciLCJFTkFCTEVfTU9VU0VfVFJBQ0tJTkciLCJFTlRFUl9BTFRfU0NSRUVOIiwiRVhJVF9BTFRfU0NSRUVOIiwiVGVybWluYWxXcml0ZUNvbnRleHQiLCJCb3giLCJUZXJtaW5hbFNpemVDb250ZXh0IiwiUHJvcHMiLCJtb3VzZVRyYWNraW5nIiwiQWx0ZXJuYXRlU2NyZWVuIiwidDAiLCIkIiwiX2MiLCJjaGlsZHJlbiIsInQxIiwidW5kZWZpbmVkIiwic2l6ZSIsIndyaXRlUmF3IiwidDIiLCJ0MyIsImluayIsImdldCIsInByb2Nlc3MiLCJzdGRvdXQiLCJzZXRBbHRTY3JlZW5BY3RpdmUiLCJjbGVhclRleHRTZWxlY3Rpb24iLCJ0NCIsInJvd3MiLCJ0NSJdLCJzb3VyY2VzIjpbIkFsdGVybmF0ZVNjcmVlbi50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7XG4gIHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4sXG4gIHVzZUNvbnRleHQsXG4gIHVzZUluc2VydGlvbkVmZmVjdCxcbn0gZnJvbSAncmVhY3QnXG5pbXBvcnQgaW5zdGFuY2VzIGZyb20gJy4uL2luc3RhbmNlcy5qcydcbmltcG9ydCB7XG4gIERJU0FCTEVfTU9VU0VfVFJBQ0tJTkcsXG4gIEVOQUJMRV9NT1VTRV9UUkFDS0lORyxcbiAgRU5URVJfQUxUX1NDUkVFTixcbiAgRVhJVF9BTFRfU0NSRUVOLFxufSBmcm9tICcuLi90ZXJtaW8vZGVjLmpzJ1xuaW1wb3J0IHsgVGVybWluYWxXcml0ZUNvbnRleHQgfSBmcm9tICcuLi91c2VUZXJtaW5hbE5vdGlmaWNhdGlvbi5qcydcbmltcG9ydCBCb3ggZnJvbSAnLi9Cb3guanMnXG5pbXBvcnQgeyBUZXJtaW5hbFNpemVDb250ZXh0IH0gZnJvbSAnLi9UZXJtaW5hbFNpemVDb250ZXh0LmpzJ1xuXG50eXBlIFByb3BzID0gUHJvcHNXaXRoQ2hpbGRyZW48e1xuICAvKiogRW5hYmxlIFNHUiBtb3VzZSB0cmFja2luZyAod2hlZWwgKyBjbGljay9kcmFnKS4gRGVmYXVsdCB0cnVlLiAqL1xuICBtb3VzZVRyYWNraW5nPzogYm9vbGVhblxufT5cblxuLyoqXG4gKiBSdW4gY2hpbGRyZW4gaW4gdGhlIHRlcm1pbmFsJ3MgYWx0ZXJuYXRlIHNjcmVlbiBidWZmZXIsIGNvbnN0cmFpbmVkIHRvXG4gKiB0aGUgdmlld3BvcnQgaGVpZ2h0LiBXaGlsZSBtb3VudGVkOlxuICpcbiAqIC0gRW50ZXJzIHRoZSBhbHQgc2NyZWVuIChERUMgMTA0OSksIGNsZWFycyBpdCwgaG9tZXMgdGhlIGN1cnNvclxuICogLSBDb25zdHJhaW5zIGl0cyBvd24gaGVpZ2h0IHRvIHRoZSB0ZXJtaW5hbCByb3cgY291bnQsIHNvIG92ZXJmbG93IG11c3RcbiAqICAgYmUgaGFuZGxlZCB2aWEgYG92ZXJmbG93OiBzY3JvbGxgIC8gZmxleGJveCAobm8gbmF0aXZlIHNjcm9sbGJhY2spXG4gKiAtIE9wdGlvbmFsbHkgZW5hYmxlcyBTR1IgbW91c2UgdHJhY2tpbmcgKHdoZWVsICsgY2xpY2svZHJhZykg4oCUIGV2ZW50c1xuICogICBzdXJmYWNlIGFzIGBQYXJzZWRLZXlgICh3aGVlbCkgYW5kIHVwZGF0ZSB0aGUgSW5rIGluc3RhbmNlJ3NcbiAqICAgc2VsZWN0aW9uIHN0YXRlIChjbGljay9kcmFnKVxuICpcbiAqIE9uIHVubW91bnQsIGRpc2FibGVzIG1vdXNlIHRyYWNraW5nIGFuZCBleGl0cyB0aGUgYWx0IHNjcmVlbiwgcmVzdG9yaW5nXG4gKiB0aGUgbWFpbiBzY3JlZW4ncyBjb250ZW50LiBTYWZlIGZvciB1c2UgaW4gY3RybC1vIHRyYW5zY3JpcHQgb3ZlcmxheXNcbiAqIGFuZCBzaW1pbGFyIHRlbXBvcmFyeSBmdWxsc2NyZWVuIHZpZXdzIOKAlCB0aGUgbWFpbiBzY3JlZW4gaXMgcHJlc2VydmVkLlxuICpcbiAqIE5vdGlmaWVzIHRoZSBJbmsgaW5zdGFuY2UgdmlhIGBzZXRBbHRTY3JlZW5BY3RpdmUoKWAgc28gdGhlIHJlbmRlcmVyXG4gKiBrZWVwcyB0aGUgY3Vyc29yIGluc2lkZSB0aGUgdmlld3BvcnQgKHByZXZlbnRpbmcgdGhlIGN1cnNvci1yZXN0b3JlIExGXG4gKiBmcm9tIHNjcm9sbGluZyBjb250ZW50KSBhbmQgc28gc2lnbmFsLWV4aXQgY2xlYW51cCBjYW4gZXhpdCB0aGUgYWx0XG4gKiBzY3JlZW4gaWYgdGhlIGNvbXBvbmVudCdzIG93biB1bm1vdW50IGRvZXNuJ3QgcnVuLlxuICovXG5leHBvcnQgZnVuY3Rpb24gQWx0ZXJuYXRlU2NyZWVuKHtcbiAgY2hpbGRyZW4sXG4gIG1vdXNlVHJhY2tpbmcgPSB0cnVlLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBzaXplID0gdXNlQ29udGV4dChUZXJtaW5hbFNpemVDb250ZXh0KVxuICBjb25zdCB3cml0ZVJhdyA9IHVzZUNvbnRleHQoVGVybWluYWxXcml0ZUNvbnRleHQpXG5cbiAgLy8gdXNlSW5zZXJ0aW9uRWZmZWN0IChub3QgdXNlTGF5b3V0RWZmZWN0KTogcmVhY3QtcmVjb25jaWxlciBjYWxsc1xuICAvLyByZXNldEFmdGVyQ29tbWl0IGJldHdlZW4gdGhlIG11dGF0aW9uIGFuZCBsYXlvdXQgY29tbWl0IHBoYXNlcywgYW5kXG4gIC8vIEluaydzIHJlc2V0QWZ0ZXJDb21taXQgdHJpZ2dlcnMgb25SZW5kZXIuIFdpdGggdXNlTGF5b3V0RWZmZWN0LCB0aGF0XG4gIC8vIGZpcnN0IG9uUmVuZGVyIGZpcmVzIEJFRk9SRSB0aGlzIGVmZmVjdCDigJQgd3JpdGluZyBhIGZ1bGwgZnJhbWUgdG8gdGhlXG4gIC8vIG1haW4gc2NyZWVuIHdpdGggYWx0U2NyZWVuPWZhbHNlLiBUaGF0IGZyYW1lIGlzIHByZXNlcnZlZCB3aGVuIHdlXG4gIC8vIGVudGVyIGFsdCBzY3JlZW4gYW5kIHJldmVhbGVkIG9uIGV4aXQgYXMgYSBicm9rZW4gdmlldy4gSW5zZXJ0aW9uXG4gIC8vIGVmZmVjdHMgZmlyZSBkdXJpbmcgdGhlIG11dGF0aW9uIHBoYXNlLCBiZWZvcmUgcmVzZXRBZnRlckNvbW1pdCwgc29cbiAgLy8gRU5URVJfQUxUX1NDUkVFTiByZWFjaGVzIHRoZSB0ZXJtaW5hbCBiZWZvcmUgdGhlIGZpcnN0IGZyYW1lIGRvZXMuXG4gIC8vIENsZWFudXAgdGltaW5nIGlzIHVuY2hhbmdlZDogYm90aCBpbnNlcnRpb24gYW5kIGxheW91dCBlZmZlY3QgY2xlYW51cFxuICAvLyBydW4gaW4gdGhlIG11dGF0aW9uIHBoYXNlIG9uIHVubW91bnQsIGJlZm9yZSByZXNldEFmdGVyQ29tbWl0LlxuICB1c2VJbnNlcnRpb25FZmZlY3QoKCkgPT4ge1xuICAgIGNvbnN0IGluayA9IGluc3RhbmNlcy5nZXQocHJvY2Vzcy5zdGRvdXQpXG4gICAgaWYgKCF3cml0ZVJhdykgcmV0dXJuXG5cbiAgICB3cml0ZVJhdyhcbiAgICAgIEVOVEVSX0FMVF9TQ1JFRU4gK1xuICAgICAgICAnXFx4MWJbMkpcXHgxYltIJyArXG4gICAgICAgIChtb3VzZVRyYWNraW5nID8gRU5BQkxFX01PVVNFX1RSQUNLSU5HIDogJycpLFxuICAgIClcbiAgICBpbms/LnNldEFsdFNjcmVlbkFjdGl2ZSh0cnVlLCBtb3VzZVRyYWNraW5nKVxuXG4gICAgcmV0dXJuICgpID0+IHtcbiAgICAgIGluaz8uc2V0QWx0U2NyZWVuQWN0aXZlKGZhbHNlKVxuICAgICAgaW5rPy5jbGVhclRleHRTZWxlY3Rpb24oKVxuICAgICAgd3JpdGVSYXcoKG1vdXNlVHJhY2tpbmcgPyBESVNBQkxFX01PVVNFX1RSQUNLSU5HIDogJycpICsgRVhJVF9BTFRfU0NSRUVOKVxuICAgIH1cbiAgfSwgW3dyaXRlUmF3LCBtb3VzZVRyYWNraW5nXSlcblxuICByZXR1cm4gKFxuICAgIDxCb3hcbiAgICAgIGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIlxuICAgICAgaGVpZ2h0PXtzaXplPy5yb3dzID8/IDI0fVxuICAgICAgd2lkdGg9XCIxMDAlXCJcbiAgICAgIGZsZXhTaHJpbms9ezB9XG4gICAgPlxuICAgICAge2NoaWxkcmVufVxuICAgIDwvQm94PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQ1YsS0FBS0MsaUJBQWlCLEVBQ3RCQyxVQUFVLEVBQ1ZDLGtCQUFrQixRQUNiLE9BQU87QUFDZCxPQUFPQyxTQUFTLE1BQU0saUJBQWlCO0FBQ3ZDLFNBQ0VDLHNCQUFzQixFQUN0QkMscUJBQXFCLEVBQ3JCQyxnQkFBZ0IsRUFDaEJDLGVBQWUsUUFDVixrQkFBa0I7QUFDekIsU0FBU0Msb0JBQW9CLFFBQVEsK0JBQStCO0FBQ3BFLE9BQU9DLEdBQUcsTUFBTSxVQUFVO0FBQzFCLFNBQVNDLG1CQUFtQixRQUFRLDBCQUEwQjtBQUU5RCxLQUFLQyxLQUFLLEdBQUdYLGlCQUFpQixDQUFDO0VBQzdCO0VBQ0FZLGFBQWEsQ0FBQyxFQUFFLE9BQU87QUFDekIsQ0FBQyxDQUFDOztBQUVGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQUFDLGdCQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQXlCO0lBQUFDLFFBQUE7SUFBQUwsYUFBQSxFQUFBTTtFQUFBLElBQUFKLEVBR3hCO0VBRE4sTUFBQUYsYUFBQSxHQUFBTSxFQUFvQixLQUFwQkMsU0FBb0IsR0FBcEIsSUFBb0IsR0FBcEJELEVBQW9CO0VBRXBCLE1BQUFFLElBQUEsR0FBYW5CLFVBQVUsQ0FBQ1MsbUJBQW1CLENBQUM7RUFDNUMsTUFBQVcsUUFBQSxHQUFpQnBCLFVBQVUsQ0FBQ08sb0JBQW9CLENBQUM7RUFBQSxJQUFBYyxFQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFSLENBQUEsUUFBQUgsYUFBQSxJQUFBRyxDQUFBLFFBQUFNLFFBQUE7SUFZOUJDLEVBQUEsR0FBQUEsQ0FBQTtNQUNqQixNQUFBRSxHQUFBLEdBQVlyQixTQUFTLENBQUFzQixHQUFJLENBQUNDLE9BQU8sQ0FBQUMsTUFBTyxDQUFDO01BQ3pDLElBQUksQ0FBQ04sUUFBUTtRQUFBO01BQUE7TUFFYkEsUUFBUSxDQUNOZixnQkFBZ0IsR0FDZCxlQUFlLElBQ2RNLGFBQWEsR0FBYlAscUJBQTBDLEdBQTFDLEVBQTBDLENBQy9DLENBQUM7TUFDRG1CLEdBQUcsRUFBQUksa0JBQXlDLENBQXBCLElBQUksRUFBRWhCLGFBQWEsQ0FBQztNQUFBLE9BRXJDO1FBQ0xZLEdBQUcsRUFBQUksa0JBQTJCLENBQU4sS0FBSyxDQUFDO1FBQzlCSixHQUFHLEVBQUFLLGtCQUFzQixDQUFELENBQUM7UUFDekJSLFFBQVEsQ0FBQyxDQUFDVCxhQUFhLEdBQWJSLHNCQUEyQyxHQUEzQyxFQUEyQyxJQUFJRyxlQUFlLENBQUM7TUFBQSxDQUMxRTtJQUFBLENBQ0Y7SUFBRWdCLEVBQUEsSUFBQ0YsUUFBUSxFQUFFVCxhQUFhLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxhQUFBO0lBQUFHLENBQUEsTUFBQU0sUUFBQTtJQUFBTixDQUFBLE1BQUFPLEVBQUE7SUFBQVAsQ0FBQSxNQUFBUSxFQUFBO0VBQUE7SUFBQUQsRUFBQSxHQUFBUCxDQUFBO0lBQUFRLEVBQUEsR0FBQVIsQ0FBQTtFQUFBO0VBaEI1QmIsa0JBQWtCLENBQUNvQixFQWdCbEIsRUFBRUMsRUFBeUIsQ0FBQztFQUtqQixNQUFBTyxFQUFBLEdBQUFWLElBQUksRUFBQVcsSUFBWSxJQUFoQixFQUFnQjtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBakIsQ0FBQSxRQUFBRSxRQUFBLElBQUFGLENBQUEsUUFBQWUsRUFBQTtJQUYxQkUsRUFBQSxJQUFDLEdBQUcsQ0FDWSxhQUFRLENBQVIsUUFBUSxDQUNkLE1BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNsQixLQUFNLENBQU4sTUFBTSxDQUNBLFVBQUMsQ0FBRCxHQUFDLENBRVpiLFNBQU8sQ0FDVixFQVBDLEdBQUcsQ0FPRTtJQUFBRixDQUFBLE1BQUFFLFFBQUE7SUFBQUYsQ0FBQSxNQUFBZSxFQUFBO0lBQUFmLENBQUEsTUFBQWlCLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFqQixDQUFBO0VBQUE7RUFBQSxPQVBOaUIsRUFPTTtBQUFBIiwiaWdub3JlTGlzdCI6W119 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx new file mode 100644 index 0000000000..d288d28bad --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -0,0 +1,748 @@ +import React, { PureComponent, type ReactNode } from 'react' + +import { updateLastInteractionTime } from '../../bootstrap/state.js' +import { logForDebugging } from '../../utils/debug.js' +import { stopCapturingEarlyInput } from '../../utils/earlyInput.js' +import { isMouseClicksDisabled } from '../../utils/fullscreen.js' +import { logError } from '../../utils/log.js' +import { EventEmitter } from '../events/emitter.js' +import { InputEvent } from '../events/input-event.js' +import { TerminalFocusEvent } from '../events/terminal-focus-event.js' +import { + INITIAL_STATE, + type ParsedInput, + type ParsedKey, + type ParsedMouse, + parseMultipleKeypresses +} from '../parse-keypress.js' +import reconciler from '../reconciler.js' +import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js' +import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js' +import { TerminalQuerier, xtversion } from '../terminal-querier.js' +import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js' +import { + DISABLE_KITTY_KEYBOARD, + DISABLE_MODIFY_OTHER_KEYS, + ENABLE_KITTY_KEYBOARD, + ENABLE_MODIFY_OTHER_KEYS, + FOCUS_IN, + FOCUS_OUT +} from '../termio/csi.js' +import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js' + +import AppContext from './AppContext.js' +import { ClockProvider } from './ClockContext.js' +import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js' +import ErrorOverview from './ErrorOverview.js' +import StdinContext from './StdinContext.js' +import { TerminalFocusProvider } from './TerminalFocusContext.js' +import { TerminalSizeContext } from './TerminalSizeContext.js' + +// Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT) +const SUPPORTS_SUSPEND = false + +// After this many milliseconds of stdin silence, the next chunk triggers +// a terminal mode re-assert (mouse tracking). Catches tmux detach→attach, +// ssh reconnect, and laptop wake — the terminal resets DEC private modes +// but no signal reaches us. 5s is well above normal inter-keystroke gaps +// but short enough that the first scroll after reattach works. +const STDIN_RESUME_GAP_MS = 5000 +type Props = { + readonly children: ReactNode + readonly stdin: NodeJS.ReadStream + readonly stdout: NodeJS.WriteStream + readonly stderr: NodeJS.WriteStream + readonly exitOnCtrlC: boolean + readonly onExit: (error?: Error) => void + readonly terminalColumns: number + readonly terminalRows: number + // Text selection state. App mutates this directly from mouse events + // and calls onSelectionChange to trigger a repaint. Mouse events only + // arrive when (or similar) enables mouse tracking, + // so the handler is always wired but dormant until tracking is on. + readonly selection: SelectionState + readonly onSelectionChange: () => void + // Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles + // onClick handlers. Returns true if a DOM handler consumed the click. + // No-op (returns false) outside fullscreen mode (Ink.dispatchClick + // gates on altScreenActive). + readonly onClickAt: (col: number, row: number) => boolean + // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over + // DOM elements. Called for mode-1003 motion events with no button held. + // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). + readonly onHoverAt: (col: number, row: number) => void + // Look up the OSC 8 hyperlink at (col, row) synchronously at click + // time. Returns the URL or undefined. The browser-open is deferred by + // MULTI_CLICK_TIMEOUT_MS so double-click can cancel it. + readonly getHyperlinkAt: (col: number, row: number) => string | undefined + // Open a hyperlink URL in the browser. Called after the timer fires. + readonly onOpenHyperlink: (url: string) => void + // Called on double/triple-click PRESS at (col, row). count=2 selects + // the word under the cursor; count=3 selects the line. Ink reads the + // screen buffer to find word/line boundaries and mutates selection, + // setting isDragging=true so a subsequent drag extends by word/line. + readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void + // Called on drag-motion. Mode-aware: char mode updates focus to the + // exact cell; word/line mode snaps to word/line boundaries. Needs + // screen-buffer access (word boundaries) so lives on Ink, not here. + readonly onSelectionDrag: (col: number, row: number) => void + // Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap. + // Ink re-asserts terminal modes: extended key reporting, and (when in + // fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the + // terminal side. Optional so testing.tsx doesn't need to stub it. + readonly onStdinResume?: () => void + // Receives the declared native-cursor position from useDeclaredCursor + // so ink.tsx can park the terminal cursor there after each frame. + // Enables IME composition at the input caret and lets screen readers / + // magnifiers track the input. Optional so testing.tsx doesn't stub it. + readonly onCursorDeclaration?: CursorDeclarationSetter + // Dispatch a keyboard event through the DOM tree. Called for each + // parsed key alongside the legacy EventEmitter path. + readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void +} + +// Multi-click detection thresholds. 500ms is the macOS default; a small +// position tolerance allows for trackpad jitter between clicks. +const MULTI_CLICK_TIMEOUT_MS = 500 +const MULTI_CLICK_DISTANCE = 1 +type State = { + readonly error?: Error +} + +// Root component for all Ink apps +// It renders stdin and stdout contexts, so that children can access them if needed +// It also handles Ctrl+C exiting and cursor visibility +export default class App extends PureComponent { + static displayName = 'InternalApp' + static getDerivedStateFromError(error: Error) { + return { + error + } + } + override state = { + error: undefined + } + + // Count how many components enabled raw mode to avoid disabling + // raw mode until all components don't need it anymore + rawModeEnabledCount = 0 + inputEmitter = new EventEmitter() + keyParseState = INITIAL_STATE + // Timer for flushing incomplete escape sequences + incompleteEscapeTimer: NodeJS.Timeout | null = null + // Timeout durations for incomplete sequences (ms) + readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences + readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations + + // Terminal query/response dispatch. Responses arrive on stdin (parsed + // out by parse-keypress) and are routed to pending promise resolvers. + querier = new TerminalQuerier(this.props.stdout) + + // Multi-click tracking for double/triple-click text selection. A click + // within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous + // click increments clickCount; otherwise it resets to 1. + lastClickTime = 0 + lastClickCol = -1 + lastClickRow = -1 + clickCount = 0 + // Deferred hyperlink-open timer — cancelled if a second click arrives + // within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects + // the word without also opening the browser). DOM onClick dispatch is + // NOT deferred — it returns true from onClickAt and skips this timer. + pendingHyperlinkTimer: ReturnType | null = null + // Last mode-1003 motion position. Terminals already dedupe to cell + // granularity but this also lets us skip dispatchHover entirely on + // repeat events (drag-then-release at same cell, etc.). + lastHoverCol = -1 + lastHoverRow = -1 + + // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, + // ssh reconnect, laptop wake) and trigger terminal mode re-assert. + // Initialized to now so startup doesn't false-trigger. + lastStdinTime = Date.now() + + // Determines if TTY is supported on the provided stdin + isRawModeSupported(): boolean { + return this.props.stdin.isTTY + } + override render() { + return ( + + + + + + {})}> + {this.state.error ? : this.props.children} + + + + + + + ) + } + override componentDidMount() { + // In accessibility mode, keep the native cursor visible for screen magnifiers and other tools + if (this.props.stdout.isTTY) { + this.props.stdout.write(HIDE_CURSOR) + } + } + override componentWillUnmount() { + if (this.props.stdout.isTTY) { + this.props.stdout.write(SHOW_CURSOR) + } + + // Clear any pending timers + if (this.incompleteEscapeTimer) { + clearTimeout(this.incompleteEscapeTimer) + this.incompleteEscapeTimer = null + } + + if (this.pendingHyperlinkTimer) { + clearTimeout(this.pendingHyperlinkTimer) + this.pendingHyperlinkTimer = null + } + + // ignore calling setRawMode on an handle stdin it cannot be called + if (this.isRawModeSupported()) { + this.handleSetRawMode(false) + } + } + override componentDidCatch(error: Error) { + this.handleExit(error) + } + handleSetRawMode = (isEnabled: boolean): void => { + const { stdin } = this.props + + if (!this.isRawModeSupported()) { + if (stdin === process.stdin) { + throw new Error( + 'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported' + ) + } else { + throw new Error( + 'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported' + ) + } + } + + stdin.setEncoding('utf8') + + if (isEnabled) { + // Ensure raw mode is enabled only once + if (this.rawModeEnabledCount === 0) { + // Stop early input capture right before we add our own readable handler. + // Both use the same stdin 'readable' + read() pattern, so they can't + // coexist -- our handler would drain stdin before Ink's can see it. + // The buffered text is preserved for REPL.tsx via consumeEarlyInput(). + stopCapturingEarlyInput() + stdin.ref() + stdin.setRawMode(true) + stdin.addListener('readable', this.handleReadable) + // Enable bracketed paste mode + this.props.stdout.write(EBP) + // Enable terminal focus reporting (DECSET 1004) + this.props.stdout.write(EFE) + + // Enable extended key reporting so ctrl+shift+ is + // distinguishable from ctrl+. We write both the kitty stack + // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — + // terminals honor whichever they implement (tmux only accepts the + // latter). + if (supportsExtendedKeys()) { + this.props.stdout.write(ENABLE_KITTY_KEYBOARD) + this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS) + } + + // Probe terminal identity. XTVERSION survives SSH (query/reply goes + // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base + // detection when env vars are absent. Fire-and-forget: the DA1 + // sentinel bounds the round-trip, and if the terminal ignores the + // query, flush() still resolves and name stays undefined. + // Deferred to next tick so it fires AFTER the current synchronous + // init sequence completes — avoids interleaving with alt-screen/mouse + // tracking enable writes that may happen in the same render cycle. + setImmediate(() => { + void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { + if (r) { + setXtversionName(r.name) + logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) + } else { + logForDebugging('XTVERSION: no reply (terminal ignored query)') + } + }) + }) + } + + this.rawModeEnabledCount++ + + return + } + + // Disable raw mode only when no components left that are using it + if (--this.rawModeEnabledCount === 0) { + this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS) + this.props.stdout.write(DISABLE_KITTY_KEYBOARD) + // Disable terminal focus reporting (DECSET 1004) + this.props.stdout.write(DFE) + // Disable bracketed paste mode + this.props.stdout.write(DBP) + stdin.setRawMode(false) + stdin.removeListener('readable', this.handleReadable) + stdin.unref() + } + } + + // Helper to flush incomplete escape sequences + flushIncomplete = (): void => { + // Clear the timer reference + this.incompleteEscapeTimer = null + + // Only proceed if we have incomplete sequences + if (!this.keyParseState.incomplete) { + return + } + + // Fullscreen: if stdin has data waiting, it's almost certainly the + // continuation of the buffered sequence (e.g. `[<64;74;16M` after a + // lone ESC). Node's event loop runs the timers phase before the poll + // phase, so when a heavy render blocks the loop past 50ms, this timer + // fires before the queued readable event even though the bytes are + // already buffered. Re-arm instead of flushing: handleReadable will + // drain stdin next and clear this timer. Prevents both the spurious + // Escape key and the lost scroll event. + if (this.props.stdin.readableLength > 0) { + this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT) + + return + } + + // Process incomplete as a flush operation (input=null) + // This reuses all existing parsing logic + this.processInput(null) + } + + // Process input through the parser and handle the results + processInput = (input: string | Buffer | null): void => { + // Parse input using our state machine + const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input) + this.keyParseState = newState + + // Process ALL keys in a SINGLE discreteUpdates call to prevent + // "Maximum update depth exceeded" error when many keys arrive at once + // (e.g., from paste operations or holding keys rapidly). + // This batches all state updates from handleInput and all useInput + // listeners together within one high-priority update context. + if (keys.length > 0) { + reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined) + } + + // If we have incomplete escape sequences, set a timer to flush them + if (this.keyParseState.incomplete) { + // Cancel any existing timer first + if (this.incompleteEscapeTimer) { + clearTimeout(this.incompleteEscapeTimer) + } + + this.incompleteEscapeTimer = setTimeout( + this.flushIncomplete, + this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT + ) + } + } + handleReadable = (): void => { + // Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake). + // The terminal may have reset DEC private modes; re-assert mouse + // tracking. Checked before the read loop so one Date.now() covers + // all chunks in this readable event. + const now = Date.now() + + if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) { + this.props.onStdinResume?.() + } + + this.lastStdinTime = now + + try { + let chunk + + while ((chunk = this.props.stdin.read() as string | null) !== null) { + // Process the input chunk + this.processInput(chunk) + } + } catch (error) { + // In Bun, an uncaught throw inside a stream 'readable' handler can + // permanently wedge the stream: data stays buffered and 'readable' + // never re-emits. Catching here ensures the stream stays healthy so + // subsequent keystrokes are still delivered. + logError(error) + + // Re-attach the listener in case the exception detached it. + // Bun may remove the listener after an error; without this, + // the session freezes permanently (stdin reader dead, event loop alive). + const { stdin } = this.props + + if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) { + logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', { + level: 'warn' + }) + stdin.addListener('readable', this.handleReadable) + } + } + } + handleInput = (input: string | undefined): void => { + // Exit on Ctrl+C + if (input === '\x03' && this.props.exitOnCtrlC) { + this.handleExit() + } + + // Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the + // parsed key to support both raw (\x1a) and CSI u format from Kitty + // keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm) + } + handleExit = (error?: Error): void => { + if (this.isRawModeSupported()) { + this.handleSetRawMode(false) + } + + this.props.onExit(error) + } + handleTerminalFocus = (isFocused: boolean): void => { + // setTerminalFocused notifies subscribers: TerminalFocusProvider (context) + // and Clock (interval speed) — no App setState needed. + setTerminalFocused(isFocused) + } + handleSuspend = (): void => { + if (!this.isRawModeSupported()) { + return + } + + // Store the exact raw mode count to restore it properly + const rawModeCountBeforeSuspend = this.rawModeEnabledCount + + // Completely disable raw mode before suspending + while (this.rawModeEnabledCount > 0) { + this.handleSetRawMode(false) + } + + // Show cursor, disable focus reporting, and disable mouse tracking + // before suspending. DISABLE_MOUSE_TRACKING is a no-op if tracking + // wasn't enabled, so it's safe to emit unconditionally — without + // it, SGR mouse sequences would appear as garbled text at the + // shell prompt while suspended. + if (this.props.stdout.isTTY) { + this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING) + } + + this.inputEmitter.emit('suspend') + + // Set up resume handler + const resumeHandler = () => { + // Restore raw mode to exact previous state + for (let i = 0; i < rawModeCountBeforeSuspend; i++) { + if (this.isRawModeSupported()) { + this.handleSetRawMode(true) + } + } + + if (this.props.stdout.isTTY) { + this.props.stdout.write(HIDE_CURSOR + EFE) + } + + this.inputEmitter.emit('resume') + process.removeListener('SIGCONT', resumeHandler) + } + + process.on('SIGCONT', resumeHandler) + process.kill(process.pid, 'SIGSTOP') + } +} + +// Helper to process all keys within a single discrete update context. +// discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d) +function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void { + // Update interaction time for notification timeout tracking. + // This is called from the central input handler to avoid having multiple + // stdin listeners that can cause race conditions and dropped input. + // Terminal responses (kind: 'response') are automated, not user input. + // Mode-1003 no-button motion is also excluded — passive cursor drift is + // not engagement (would suppress idle notifications + defer housekeeping). + if ( + items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3))) + ) { + updateLastInteractionTime() + } + + for (const item of items) { + // Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user + // input — route them to the querier to resolve pending promises. + if (item.kind === 'response') { + app.querier.onResponse(item.response) + + continue + } + + // Mouse click/drag events update selection state (fullscreen only). + // Terminal sends 1-indexed col/row; convert to 0-indexed for the + // screen buffer. Button bit 0x20 = drag (motion while button held). + if (item.kind === 'mouse') { + handleMouseEvent(app, item) + + continue + } + + const sequence = item.sequence + + // Handle terminal focus events (DECSET 1004) + if (sequence === FOCUS_IN) { + app.handleTerminalFocus(true) + const event = new TerminalFocusEvent('terminalfocus') + app.inputEmitter.emit('terminalfocus', event) + + continue + } + + if (sequence === FOCUS_OUT) { + app.handleTerminalFocus(false) + + // Defensive: if we lost the release event (mouse released outside + // terminal window — some emulators drop it rather than capturing the + // pointer), focus-out is the next observable signal that the drag is + // over. Without this, drag-to-scroll's timer runs until the scroll + // boundary is hit. + if (app.props.selection.isDragging) { + finishSelection(app.props.selection) + app.props.onSelectionChange() + } + + const event = new TerminalFocusEvent('terminalblur') + app.inputEmitter.emit('terminalblur', event) + + continue + } + + // Failsafe: if we receive input, the terminal must be focused + if (!getTerminalFocused()) { + setTerminalFocused(true) + } + + // Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and + // CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals + if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) { + app.handleSuspend() + + continue + } + + app.handleInput(sequence) + const event = new InputEvent(item) + app.inputEmitter.emit('input', event) + + // Also dispatch through the DOM tree so onKeyDown handlers fire. + app.props.dispatchKeyboardEvent(item) + } +} + +/** Exported for testing. Mutates app.props.selection and click/hover state. */ +export function handleMouseEvent(app: App, m: ParsedMouse): void { + // Allow disabling click handling while keeping wheel scroll (which goes + // through the keybinding system as 'wheelup'/'wheeldown', not here). + if (isMouseClicksDisabled()) { + return + } + + const sel = app.props.selection + // Terminal coords are 1-indexed; screen buffer is 0-indexed + const col = m.col - 1 + const row = m.row - 1 + const baseButton = m.button & 0x03 + + if (m.action === 'press') { + if ((m.button & 0x20) !== 0 && baseButton === 3) { + // Mode-1003 motion with no button held. Dispatch hover; skip the + // rest of this handler (no selection, no click-count side effects). + // Lost-release recovery: no-button motion while isDragging=true means + // the release happened outside the terminal window (iTerm2 doesn't + // capture the pointer past window bounds, so the SGR 'm' never + // arrives). Finish the selection here so copy-on-select fires. The + // FOCUS_OUT handler covers the "switched apps" case but not "released + // past the edge, came back" — and tmux drops focus events unless + // `focus-events on` is set, so this is the more reliable signal. + if (sel.isDragging) { + finishSelection(sel) + app.props.onSelectionChange() + } + + if (col === app.lastHoverCol && row === app.lastHoverRow) { + return + } + + app.lastHoverCol = col + app.lastHoverRow = row + app.props.onHoverAt(col, row) + + return + } + + if (baseButton !== 0) { + // Non-left press breaks the multi-click chain. + app.clickCount = 0 + + return + } + + if ((m.button & 0x20) !== 0) { + // Drag motion: mode-aware extension (char/word/line). onSelectionDrag + // calls notifySelectionChange internally — no extra onSelectionChange. + app.props.onSelectionDrag(col, row) + + return + } + + // Lost-release fallback for mode-1002-only terminals: a fresh press + // while isDragging=true means the previous release was dropped (cursor + // left the window). Finish that selection so copy-on-select fires + // before startSelection/onMultiClick clobbers it. Mode-1003 terminals + // hit the no-button-motion recovery above instead, so this is rare. + if (sel.isDragging) { + finishSelection(sel) + app.props.onSelectionChange() + } + + // Fresh left press. Detect multi-click HERE (not on release) so the + // word/line highlight appears immediately and a subsequent drag can + // extend by word/line like native macOS. Previously detected on + // release, which meant (a) visible latency before the word highlights + // and (b) double-click+drag fell through to char-mode selection. + const now = Date.now() + + const nearLast = + now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && + Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && + Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE + + app.clickCount = nearLast ? app.clickCount + 1 : 1 + app.lastClickTime = now + app.lastClickCol = col + app.lastClickRow = row + + if (app.clickCount >= 2) { + // Cancel any pending hyperlink-open from the first click — this is + // a double-click, not a single-click on a link. + if (app.pendingHyperlinkTimer) { + clearTimeout(app.pendingHyperlinkTimer) + app.pendingHyperlinkTimer = null + } + + // Cap at 3 (line select) for quadruple+ clicks. + const count = app.clickCount === 2 ? 2 : 3 + app.props.onMultiClick(col, row, count) + + return + } + + startSelection(sel, col, row) + // SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see + // comment at the hyperlink-open guard below). On macOS xterm.js, + // receiving alt means macOptionClickForcesSelection is OFF (otherwise + // xterm.js would have consumed the event for native selection). + sel.lastPressHadAlt = (m.button & 0x08) !== 0 + app.props.onSelectionChange() + + return + } + + // Release: end the drag even for non-zero button codes. Some terminals + // encode release with the motion bit or button=3 "no button" (carried + // over from pre-SGR X10 encoding) — filtering those would orphan + // isDragging=true and leave drag-to-scroll's timer running until the + // scroll boundary. Only act on non-left releases when we ARE dragging + // (so an unrelated middle/right click-release doesn't touch selection). + if (baseButton !== 0) { + if (!sel.isDragging) { + return + } + + finishSelection(sel) + app.props.onSelectionChange() + + return + } + + finishSelection(sel) + + // NOTE: unlike the old release-based detection we do NOT reset clickCount + // on release-after-drag. This aligns with NSEvent.clickCount semantics: + // an intervening drag doesn't break the click chain. Practical upside: + // trackpad jitter during an intended double-click (press→wobble→release + // →press) now correctly resolves to word-select instead of breaking to a + // fresh single click. The nearLast window (500ms, 1 cell) bounds the + // effect — a deliberate drag past that just starts a fresh chain. + // A press+release with no drag in char mode is a click: anchor set, + // focus null → hasSelection false. In word/line mode the press already + // set anchor+focus (hasSelection true), so release just keeps the + // highlight. The anchor check guards against an orphaned release (no + // prior press — e.g. button was held when mouse tracking was enabled). + if (!hasSelection(sel) && sel.anchor) { + // Single click: dispatch DOM click immediately (cursor repositioning + // etc. are latency-sensitive). If no DOM handler consumed it, defer + // the hyperlink check so a second click can cancel it. + if (!app.props.onClickAt(col, row)) { + // Resolve the hyperlink URL synchronously while the screen buffer + // still reflects what the user clicked — deferring only the + // browser-open so double-click can cancel it. + const url = app.props.getHyperlinkAt(col, row) + + // xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link + // handler that fires on Cmd+click *without consuming the mouse event* + // (Linkifier._handleMouseUp calls link.activate() but never + // preventDefault/stopPropagation). The click is also forwarded to the + // pty as SGR, so both VS Code's terminalLinkManager AND our handler + // here would open the URL — twice. We can't filter on Cmd: xterm.js + // drops metaKey before SGR encoding (ICoreMouseEvent has no meta + // field; the SGR bit we call 'meta' is wired to alt). Let xterm.js + // own link-opening; Cmd+click is the native UX there anyway. + // TERM_PROGRAM is the sync fast-path; isXtermJs() is the XTVERSION + // probe result (catches SSH + non-VS Code embedders like Hyper). + if (url && process.env.TERM_PROGRAM !== 'vscode' && !isXtermJs()) { + // Clear any prior pending timer — clicking a second link + // supersedes the first (only the latest click opens). + if (app.pendingHyperlinkTimer) { + clearTimeout(app.pendingHyperlinkTimer) + } + + app.pendingHyperlinkTimer = setTimeout( + (app, url) => { + app.pendingHyperlinkTimer = null + app.props.onOpenHyperlink(url) + }, + MULTI_CLICK_TIMEOUT_MS, + app, + url + ) + } + } + } + + app.props.onSelectionChange() +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlB1cmVDb21wb25lbnQiLCJSZWFjdE5vZGUiLCJ1cGRhdGVMYXN0SW50ZXJhY3Rpb25UaW1lIiwibG9nRm9yRGVidWdnaW5nIiwic3RvcENhcHR1cmluZ0Vhcmx5SW5wdXQiLCJpc0VudlRydXRoeSIsImlzTW91c2VDbGlja3NEaXNhYmxlZCIsImxvZ0Vycm9yIiwiRXZlbnRFbWl0dGVyIiwiSW5wdXRFdmVudCIsIlRlcm1pbmFsRm9jdXNFdmVudCIsIklOSVRJQUxfU1RBVEUiLCJQYXJzZWRJbnB1dCIsIlBhcnNlZEtleSIsIlBhcnNlZE1vdXNlIiwicGFyc2VNdWx0aXBsZUtleXByZXNzZXMiLCJyZWNvbmNpbGVyIiwiZmluaXNoU2VsZWN0aW9uIiwiaGFzU2VsZWN0aW9uIiwiU2VsZWN0aW9uU3RhdGUiLCJzdGFydFNlbGVjdGlvbiIsImlzWHRlcm1KcyIsInNldFh0dmVyc2lvbk5hbWUiLCJzdXBwb3J0c0V4dGVuZGVkS2V5cyIsImdldFRlcm1pbmFsRm9jdXNlZCIsInNldFRlcm1pbmFsRm9jdXNlZCIsIlRlcm1pbmFsUXVlcmllciIsInh0dmVyc2lvbiIsIkRJU0FCTEVfS0lUVFlfS0VZQk9BUkQiLCJESVNBQkxFX01PRElGWV9PVEhFUl9LRVlTIiwiRU5BQkxFX0tJVFRZX0tFWUJPQVJEIiwiRU5BQkxFX01PRElGWV9PVEhFUl9LRVlTIiwiRk9DVVNfSU4iLCJGT0NVU19PVVQiLCJEQlAiLCJERkUiLCJESVNBQkxFX01PVVNFX1RSQUNLSU5HIiwiRUJQIiwiRUZFIiwiSElERV9DVVJTT1IiLCJTSE9XX0NVUlNPUiIsIkFwcENvbnRleHQiLCJDbG9ja1Byb3ZpZGVyIiwiQ3Vyc29yRGVjbGFyYXRpb25Db250ZXh0IiwiQ3Vyc29yRGVjbGFyYXRpb25TZXR0ZXIiLCJFcnJvck92ZXJ2aWV3IiwiU3RkaW5Db250ZXh0IiwiVGVybWluYWxGb2N1c1Byb3ZpZGVyIiwiVGVybWluYWxTaXplQ29udGV4dCIsIlNVUFBPUlRTX1NVU1BFTkQiLCJwcm9jZXNzIiwicGxhdGZvcm0iLCJTVERJTl9SRVNVTUVfR0FQX01TIiwiUHJvcHMiLCJjaGlsZHJlbiIsInN0ZGluIiwiTm9kZUpTIiwiUmVhZFN0cmVhbSIsInN0ZG91dCIsIldyaXRlU3RyZWFtIiwic3RkZXJyIiwiZXhpdE9uQ3RybEMiLCJvbkV4aXQiLCJlcnJvciIsIkVycm9yIiwidGVybWluYWxDb2x1bW5zIiwidGVybWluYWxSb3dzIiwic2VsZWN0aW9uIiwib25TZWxlY3Rpb25DaGFuZ2UiLCJvbkNsaWNrQXQiLCJjb2wiLCJyb3ciLCJvbkhvdmVyQXQiLCJnZXRIeXBlcmxpbmtBdCIsIm9uT3Blbkh5cGVybGluayIsInVybCIsIm9uTXVsdGlDbGljayIsImNvdW50Iiwib25TZWxlY3Rpb25EcmFnIiwib25TdGRpblJlc3VtZSIsIm9uQ3Vyc29yRGVjbGFyYXRpb24iLCJkaXNwYXRjaEtleWJvYXJkRXZlbnQiLCJwYXJzZWRLZXkiLCJNVUxUSV9DTElDS19USU1FT1VUX01TIiwiTVVMVElfQ0xJQ0tfRElTVEFOQ0UiLCJTdGF0ZSIsIkFwcCIsImRpc3BsYXlOYW1lIiwiZ2V0RGVyaXZlZFN0YXRlRnJvbUVycm9yIiwic3RhdGUiLCJ1bmRlZmluZWQiLCJyYXdNb2RlRW5hYmxlZENvdW50IiwiaW50ZXJuYWxfZXZlbnRFbWl0dGVyIiwia2V5UGFyc2VTdGF0ZSIsImluY29tcGxldGVFc2NhcGVUaW1lciIsIlRpbWVvdXQiLCJOT1JNQUxfVElNRU9VVCIsIlBBU1RFX1RJTUVPVVQiLCJxdWVyaWVyIiwicHJvcHMiLCJsYXN0Q2xpY2tUaW1lIiwibGFzdENsaWNrQ29sIiwibGFzdENsaWNrUm93IiwiY2xpY2tDb3VudCIsInBlbmRpbmdIeXBlcmxpbmtUaW1lciIsIlJldHVyblR5cGUiLCJzZXRUaW1lb3V0IiwibGFzdEhvdmVyQ29sIiwibGFzdEhvdmVyUm93IiwibGFzdFN0ZGluVGltZSIsIkRhdGUiLCJub3ciLCJpc1Jhd01vZGVTdXBwb3J0ZWQiLCJpc1RUWSIsInJlbmRlciIsImNvbHVtbnMiLCJyb3dzIiwiZXhpdCIsImhhbmRsZUV4aXQiLCJzZXRSYXdNb2RlIiwiaGFuZGxlU2V0UmF3TW9kZSIsImludGVybmFsX2V4aXRPbkN0cmxDIiwiaW50ZXJuYWxfcXVlcmllciIsImNvbXBvbmVudERpZE1vdW50IiwiZW52IiwiQ0xBVURFX0NPREVfQUNDRVNTSUJJTElUWSIsIndyaXRlIiwiY29tcG9uZW50V2lsbFVubW91bnQiLCJjbGVhclRpbWVvdXQiLCJjb21wb25lbnREaWRDYXRjaCIsImlzRW5hYmxlZCIsInNldEVuY29kaW5nIiwicmVmIiwiYWRkTGlzdGVuZXIiLCJoYW5kbGVSZWFkYWJsZSIsInNldEltbWVkaWF0ZSIsIlByb21pc2UiLCJhbGwiLCJzZW5kIiwiZmx1c2giLCJ0aGVuIiwiciIsIm5hbWUiLCJyZW1vdmVMaXN0ZW5lciIsInVucmVmIiwiZmx1c2hJbmNvbXBsZXRlIiwiaW5jb21wbGV0ZSIsInJlYWRhYmxlTGVuZ3RoIiwicHJvY2Vzc0lucHV0IiwiaW5wdXQiLCJCdWZmZXIiLCJrZXlzIiwibmV3U3RhdGUiLCJsZW5ndGgiLCJkaXNjcmV0ZVVwZGF0ZXMiLCJwcm9jZXNzS2V5c0luQmF0Y2giLCJtb2RlIiwiY2h1bmsiLCJyZWFkIiwibGlzdGVuZXJzIiwiaW5jbHVkZXMiLCJsZXZlbCIsImhhbmRsZUlucHV0IiwiaGFuZGxlVGVybWluYWxGb2N1cyIsImlzRm9jdXNlZCIsImhhbmRsZVN1c3BlbmQiLCJyYXdNb2RlQ291bnRCZWZvcmVTdXNwZW5kIiwiZW1pdCIsInJlc3VtZUhhbmRsZXIiLCJpIiwib24iLCJraWxsIiwicGlkIiwiYXBwIiwiaXRlbXMiLCJfdW51c2VkMSIsIl91bnVzZWQyIiwic29tZSIsImtpbmQiLCJidXR0b24iLCJpdGVtIiwib25SZXNwb25zZSIsInJlc3BvbnNlIiwiaGFuZGxlTW91c2VFdmVudCIsInNlcXVlbmNlIiwiZXZlbnQiLCJpc0RyYWdnaW5nIiwiY3RybCIsIm0iLCJzZWwiLCJiYXNlQnV0dG9uIiwiYWN0aW9uIiwibmVhckxhc3QiLCJNYXRoIiwiYWJzIiwibGFzdFByZXNzSGFkQWx0IiwiYW5jaG9yIiwiVEVSTV9QUk9HUkFNIl0sInNvdXJjZXMiOlsiQXBwLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHsgUHVyZUNvbXBvbmVudCwgdHlwZSBSZWFjdE5vZGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVwZGF0ZUxhc3RJbnRlcmFjdGlvblRpbWUgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgeyBsb2dGb3JEZWJ1Z2dpbmcgfSBmcm9tICcuLi8uLi91dGlscy9kZWJ1Zy5qcydcbmltcG9ydCB7IHN0b3BDYXB0dXJpbmdFYXJseUlucHV0IH0gZnJvbSAnLi4vLi4vdXRpbHMvZWFybHlJbnB1dC5qcydcbmltcG9ydCB7IGlzRW52VHJ1dGh5IH0gZnJvbSAnLi4vLi4vdXRpbHMvZW52VXRpbHMuanMnXG5pbXBvcnQgeyBpc01vdXNlQ2xpY2tzRGlzYWJsZWQgfSBmcm9tICcuLi8uLi91dGlscy9mdWxsc2NyZWVuLmpzJ1xuaW1wb3J0IHsgbG9nRXJyb3IgfSBmcm9tICcuLi8uLi91dGlscy9sb2cuanMnXG5pbXBvcnQgeyBFdmVudEVtaXR0ZXIgfSBmcm9tICcuLi9ldmVudHMvZW1pdHRlci5qcydcbmltcG9ydCB7IElucHV0RXZlbnQgfSBmcm9tICcuLi9ldmVudHMvaW5wdXQtZXZlbnQuanMnXG5pbXBvcnQgeyBUZXJtaW5hbEZvY3VzRXZlbnQgfSBmcm9tICcuLi9ldmVudHMvdGVybWluYWwtZm9jdXMtZXZlbnQuanMnXG5pbXBvcnQge1xuICBJTklUSUFMX1NUQVRFLFxuICB0eXBlIFBhcnNlZElucHV0LFxuICB0eXBlIFBhcnNlZEtleSxcbiAgdHlwZSBQYXJzZWRNb3VzZSxcbiAgcGFyc2VNdWx0aXBsZUtleXByZXNzZXMsXG59IGZyb20gJy4uL3BhcnNlLWtleXByZXNzLmpzJ1xuaW1wb3J0IHJlY29uY2lsZXIgZnJvbSAnLi4vcmVjb25jaWxlci5qcydcbmltcG9ydCB7XG4gIGZpbmlzaFNlbGVjdGlvbixcbiAgaGFzU2VsZWN0aW9uLFxuICB0eXBlIFNlbGVjdGlvblN0YXRlLFxuICBzdGFydFNlbGVjdGlvbixcbn0gZnJvbSAnLi4vc2VsZWN0aW9uLmpzJ1xuaW1wb3J0IHtcbiAgaXNYdGVybUpzLFxuICBzZXRYdHZlcnNpb25OYW1lLFxuICBzdXBwb3J0c0V4dGVuZGVkS2V5cyxcbn0gZnJvbSAnLi4vdGVybWluYWwuanMnXG5pbXBvcnQge1xuICBnZXRUZXJtaW5hbEZvY3VzZWQsXG4gIHNldFRlcm1pbmFsRm9jdXNlZCxcbn0gZnJvbSAnLi4vdGVybWluYWwtZm9jdXMtc3RhdGUuanMnXG5pbXBvcnQgeyBUZXJtaW5hbFF1ZXJpZXIsIHh0dmVyc2lvbiB9IGZyb20gJy4uL3Rlcm1pbmFsLXF1ZXJpZXIuanMnXG5pbXBvcnQge1xuICBESVNBQkxFX0tJVFRZX0tFWUJPQVJELFxuICBESVNBQkxFX01PRElGWV9PVEhFUl9LRVlTLFxuICBFTkFCTEVfS0lUVFlfS0VZQk9BUkQsXG4gIEVOQUJMRV9NT0RJRllfT1RIRVJfS0VZUyxcbiAgRk9DVVNfSU4sXG4gIEZPQ1VTX09VVCxcbn0gZnJvbSAnLi4vdGVybWlvL2NzaS5qcydcbmltcG9ydCB7XG4gIERCUCxcbiAgREZFLFxuICBESVNBQkxFX01PVVNFX1RSQUNLSU5HLFxuICBFQlAsXG4gIEVGRSxcbiAgSElERV9DVVJTT1IsXG4gIFNIT1dfQ1VSU09SLFxufSBmcm9tICcuLi90ZXJtaW8vZGVjLmpzJ1xuaW1wb3J0IEFwcENvbnRleHQgZnJvbSAnLi9BcHBDb250ZXh0LmpzJ1xuaW1wb3J0IHsgQ2xvY2tQcm92aWRlciB9IGZyb20gJy4vQ2xvY2tDb250ZXh0LmpzJ1xuaW1wb3J0IEN1cnNvckRlY2xhcmF0aW9uQ29udGV4dCwge1xuICB0eXBlIEN1cnNvckRlY2xhcmF0aW9uU2V0dGVyLFxufSBmcm9tICcuL0N1cnNvckRlY2xhcmF0aW9uQ29udGV4dC5qcydcbmltcG9ydCBFcnJvck92ZXJ2aWV3IGZyb20gJy4vRXJyb3JPdmVydmlldy5qcydcbmltcG9ydCBTdGRpbkNvbnRleHQgZnJvbSAnLi9TdGRpbkNvbnRleHQuanMnXG5pbXBvcnQgeyBUZXJtaW5hbEZvY3VzUHJvdmlkZXIgfSBmcm9tICcuL1Rlcm1pbmFsRm9jdXNDb250ZXh0LmpzJ1xuaW1wb3J0IHsgVGVybWluYWxTaXplQ29udGV4dCB9IGZyb20gJy4vVGVybWluYWxTaXplQ29udGV4dC5qcydcblxuLy8gUGxhdGZvcm1zIHRoYXQgc3VwcG9ydCBVbml4LXN0eWxlIHByb2Nlc3Mgc3VzcGVuc2lvbiAoU0lHU1RPUC9TSUdDT05UKVxuY29uc3QgU1VQUE9SVFNfU1VTUEVORCA9IHByb2Nlc3MucGxhdGZvcm0gIT09ICd3aW4zMidcblxuLy8gQWZ0ZXIgdGhpcyBtYW55IG1pbGxpc2Vjb25kcyBvZiBzdGRpbiBzaWxlbmNlLCB0aGUgbmV4dCBjaHVuayB0cmlnZ2Vyc1xuLy8gYSB0ZXJtaW5hbCBtb2RlIHJlLWFzc2VydCAobW91c2UgdHJhY2tpbmcpLiBDYXRjaGVzIHRtdXggZGV0YWNo4oaSYXR0YWNoLFxuLy8gc3NoIHJlY29ubmVjdCwgYW5kIGxhcHRvcCB3YWtlIOKAlCB0aGUgdGVybWluYWwgcmVzZXRzIERFQyBwcml2YXRlIG1vZGVzXG4vLyBidXQgbm8gc2lnbmFsIHJlYWNoZXMgdXMuIDVzIGlzIHdlbGwgYWJvdmUgbm9ybWFsIGludGVyLWtleXN0cm9rZSBnYXBzXG4vLyBidXQgc2hvcnQgZW5vdWdoIHRoYXQgdGhlIGZpcnN0IHNjcm9sbCBhZnRlciByZWF0dGFjaCB3b3Jrcy5cbmNvbnN0IFNURElOX1JFU1VNRV9HQVBfTVMgPSA1MDAwXG5cbnR5cGUgUHJvcHMgPSB7XG4gIHJlYWRvbmx5IGNoaWxkcmVuOiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgc3RkaW46IE5vZGVKUy5SZWFkU3RyZWFtXG4gIHJlYWRvbmx5IHN0ZG91dDogTm9kZUpTLldyaXRlU3RyZWFtXG4gIHJlYWRvbmx5IHN0ZGVycjogTm9kZUpTLldyaXRlU3RyZWFtXG4gIHJlYWRvbmx5IGV4aXRPbkN0cmxDOiBib29sZWFuXG4gIHJlYWRvbmx5IG9uRXhpdDogKGVycm9yPzogRXJyb3IpID0+IHZvaWRcbiAgcmVhZG9ubHkgdGVybWluYWxDb2x1bW5zOiBudW1iZXJcbiAgcmVhZG9ubHkgdGVybWluYWxSb3dzOiBudW1iZXJcbiAgLy8gVGV4dCBzZWxlY3Rpb24gc3RhdGUuIEFwcCBtdXRhdGVzIHRoaXMgZGlyZWN0bHkgZnJvbSBtb3VzZSBldmVudHNcbiAgLy8gYW5kIGNhbGxzIG9uU2VsZWN0aW9uQ2hhbmdlIHRvIHRyaWdnZXIgYSByZXBhaW50LiBNb3VzZSBldmVudHMgb25seVxuICAvLyBhcnJpdmUgd2hlbiA8QWx0ZXJuYXRlU2NyZWVuPiAob3Igc2ltaWxhcikgZW5hYmxlcyBtb3VzZSB0cmFja2luZyxcbiAgLy8gc28gdGhlIGhhbmRsZXIgaXMgYWx3YXlzIHdpcmVkIGJ1dCBkb3JtYW50IHVudGlsIHRyYWNraW5nIGlzIG9uLlxuICByZWFkb25seSBzZWxlY3Rpb246IFNlbGVjdGlvblN0YXRlXG4gIHJlYWRvbmx5IG9uU2VsZWN0aW9uQ2hhbmdlOiAoKSA9PiB2b2lkXG4gIC8vIERpc3BhdGNoIGEgY2xpY2sgYXQgKGNvbCwgcm93KSDigJQgaGl0LXRlc3RzIHRoZSBET00gdHJlZSBhbmQgYnViYmxlc1xuICAvLyBvbkNsaWNrIGhhbmRsZXJzLiBSZXR1cm5zIHRydWUgaWYgYSBET00gaGFuZGxlciBjb25zdW1lZCB0aGUgY2xpY2suXG4gIC8vIE5vLW9wIChyZXR1cm5zIGZhbHNlKSBvdXRzaWRlIGZ1bGxzY3JlZW4gbW9kZSAoSW5rLmRpc3BhdGNoQ2xpY2tcbiAgLy8gZ2F0ZXMgb24gYWx0U2NyZWVuQWN0aXZlKS5cbiAgcmVhZG9ubHkgb25DbGlja0F0OiAoY29sOiBudW1iZXIsIHJvdzogbnVtYmVyKSA9PiBib29sZWFuXG4gIC8vIERpc3BhdGNoIGhvdmVyIChvbk1vdXNlRW50ZXIvb25Nb3VzZUxlYXZlKSBhcyB0aGUgcG9pbnRlciBtb3ZlcyBvdmVyXG4gIC8vIERPTSBlbGVtZW50cy4gQ2FsbGVkIGZvciBtb2RlLTEwMDMgbW90aW9uIGV2ZW50cyB3aXRoIG5vIGJ1dHRvbiBoZWxkLlxuICAvLyBOby1vcCBvdXRzaWRlIGZ1bGxzY3JlZW4gKEluay5kaXNwYXRjaEhvdmVyIGdhdGVzIG9uIGFsdFNjcmVlbkFjdGl2ZSkuXG4gIHJlYWRvbmx5IG9uSG92ZXJBdDogKGNvbDogbnVtYmVyLCByb3c6IG51bWJlcikgPT4gdm9pZFxuICAvLyBMb29rIHVwIHRoZSBPU0MgOCBoeXBlcmxpbmsgYXQgKGNvbCwgcm93KSBzeW5jaHJvbm91c2x5IGF0IGNsaWNrXG4gIC8vIHRpbWUuIFJldHVybnMgdGhlIFVSTCBvciB1bmRlZmluZWQuIFRoZSBicm93c2VyLW9wZW4gaXMgZGVmZXJyZWQgYnlcbiAgLy8gTVVMVElfQ0xJQ0tfVElNRU9VVF9NUyBzbyBkb3VibGUtY2xpY2sgY2FuIGNhbmNlbCBpdC5cbiAgcmVhZG9ubHkgZ2V0SHlwZXJsaW5rQXQ6IChjb2w6IG51bWJlciwgcm93OiBudW1iZXIpID0+IHN0cmluZyB8IHVuZGVmaW5lZFxuICAvLyBPcGVuIGEgaHlwZXJsaW5rIFVSTCBpbiB0aGUgYnJvd3Nlci4gQ2FsbGVkIGFmdGVyIHRoZSB0aW1lciBmaXJlcy5cbiAgcmVhZG9ubHkgb25PcGVuSHlwZXJsaW5rOiAodXJsOiBzdHJpbmcpID0+IHZvaWRcbiAgLy8gQ2FsbGVkIG9uIGRvdWJsZS90cmlwbGUtY2xpY2sgUFJFU1MgYXQgKGNvbCwgcm93KS4gY291bnQ9MiBzZWxlY3RzXG4gIC8vIHRoZSB3b3JkIHVuZGVyIHRoZSBjdXJzb3I7IGNvdW50PTMgc2VsZWN0cyB0aGUgbGluZS4gSW5rIHJlYWRzIHRoZVxuICAvLyBzY3JlZW4gYnVmZmVyIHRvIGZpbmQgd29yZC9saW5lIGJvdW5kYXJpZXMgYW5kIG11dGF0ZXMgc2VsZWN0aW9uLFxuICAvLyBzZXR0aW5nIGlzRHJhZ2dpbmc9dHJ1ZSBzbyBhIHN1YnNlcXVlbnQgZHJhZyBleHRlbmRzIGJ5IHdvcmQvbGluZS5cbiAgcmVhZG9ubHkgb25NdWx0aUNsaWNrOiAoY29sOiBudW1iZXIsIHJvdzogbnVtYmVyLCBjb3VudDogMiB8IDMpID0+IHZvaWRcbiAgLy8gQ2FsbGVkIG9uIGRyYWctbW90aW9uLiBNb2RlLWF3YXJlOiBjaGFyIG1vZGUgdXBkYXRlcyBmb2N1cyB0byB0aGVcbiAgLy8gZXhhY3QgY2VsbDsgd29yZC9saW5lIG1vZGUgc25hcHMgdG8gd29yZC9saW5lIGJvdW5kYXJpZXMuIE5lZWRzXG4gIC8vIHNjcmVlbi1idWZmZXIgYWNjZXNzICh3b3JkIGJvdW5kYXJpZXMpIHNvIGxpdmVzIG9uIEluaywgbm90IGhlcmUuXG4gIHJlYWRvbmx5IG9uU2VsZWN0aW9uRHJhZzogKGNvbDogbnVtYmVyLCByb3c6IG51bWJlcikgPT4gdm9pZFxuICAvLyBDYWxsZWQgd2hlbiBzdGRpbiBkYXRhIGFycml2ZXMgYWZ0ZXIgYSA+U1RESU5fUkVTVU1FX0dBUF9NUyBnYXAuXG4gIC8vIEluayByZS1hc3NlcnRzIHRlcm1pbmFsIG1vZGVzOiBleHRlbmRlZCBrZXkgcmVwb3J0aW5nLCBhbmQgKHdoZW4gaW5cbiAgLy8gZnVsbHNjcmVlbikgcmUtZW50ZXJzIGFsdC1zY3JlZW4gKyBtb3VzZSB0cmFja2luZy4gSWRlbXBvdGVudCBvbiB0aGVcbiAgLy8gdGVybWluYWwgc2lkZS4gT3B0aW9uYWwgc28gdGVzdGluZy50c3ggZG9lc24ndCBuZWVkIHRvIHN0dWIgaXQuXG4gIHJlYWRvbmx5IG9uU3RkaW5SZXN1bWU/OiAoKSA9PiB2b2lkXG4gIC8vIFJlY2VpdmVzIHRoZSBkZWNsYXJlZCBuYXRpdmUtY3Vyc29yIHBvc2l0aW9uIGZyb20gdXNlRGVjbGFyZWRDdXJzb3JcbiAgLy8gc28gaW5rLnRzeCBjYW4gcGFyayB0aGUgdGVybWluYWwgY3Vyc29yIHRoZXJlIGFmdGVyIGVhY2ggZnJhbWUuXG4gIC8vIEVuYWJsZXMgSU1FIGNvbXBvc2l0aW9uIGF0IHRoZSBpbnB1dCBjYXJldCBhbmQgbGV0cyBzY3JlZW4gcmVhZGVycyAvXG4gIC8vIG1hZ25pZmllcnMgdHJhY2sgdGhlIGlucHV0LiBPcHRpb25hbCBzbyB0ZXN0aW5nLnRzeCBkb2Vzbid0IHN0dWIgaXQuXG4gIHJlYWRvbmx5IG9uQ3Vyc29yRGVjbGFyYXRpb24/OiBDdXJzb3JEZWNsYXJhdGlvblNldHRlclxuICAvLyBEaXNwYXRjaCBhIGtleWJvYXJkIGV2ZW50IHRocm91Z2ggdGhlIERPTSB0cmVlLiBDYWxsZWQgZm9yIGVhY2hcbiAgLy8gcGFyc2VkIGtleSBhbG9uZ3NpZGUgdGhlIGxlZ2FjeSBFdmVudEVtaXR0ZXIgcGF0aC5cbiAgcmVhZG9ubHkgZGlzcGF0Y2hLZXlib2FyZEV2ZW50OiAocGFyc2VkS2V5OiBQYXJzZWRLZXkpID0+IHZvaWRcbn1cblxuLy8gTXVsdGktY2xpY2sgZGV0ZWN0aW9uIHRocmVzaG9sZHMuIDUwMG1zIGlzIHRoZSBtYWNPUyBkZWZhdWx0OyBhIHNtYWxsXG4vLyBwb3NpdGlvbiB0b2xlcmFuY2UgYWxsb3dzIGZvciB0cmFja3BhZCBqaXR0ZXIgYmV0d2VlbiBjbGlja3MuXG5jb25zdCBNVUxUSV9DTElDS19USU1FT1VUX01TID0gNTAwXG5jb25zdCBNVUxUSV9DTElDS19ESVNUQU5DRSA9IDFcblxudHlwZSBTdGF0ZSA9IHtcbiAgcmVhZG9ubHkgZXJyb3I/OiBFcnJvclxufVxuXG4vLyBSb290IGNvbXBvbmVudCBmb3IgYWxsIEluayBhcHBzXG4vLyBJdCByZW5kZXJzIHN0ZGluIGFuZCBzdGRvdXQgY29udGV4dHMsIHNvIHRoYXQgY2hpbGRyZW4gY2FuIGFjY2VzcyB0aGVtIGlmIG5lZWRlZFxuLy8gSXQgYWxzbyBoYW5kbGVzIEN0cmwrQyBleGl0aW5nIGFuZCBjdXJzb3IgdmlzaWJpbGl0eVxuZXhwb3J0IGRlZmF1bHQgY2xhc3MgQXBwIGV4dGVuZHMgUHVyZUNvbXBvbmVudDxQcm9wcywgU3RhdGU+IHtcbiAgc3RhdGljIGRpc3BsYXlOYW1lID0gJ0ludGVybmFsQXBwJ1xuXG4gIHN0YXRpYyBnZXREZXJpdmVkU3RhdGVGcm9tRXJyb3IoZXJyb3I6IEVycm9yKSB7XG4gICAgcmV0dXJuIHsgZXJyb3IgfVxuICB9XG5cbiAgb3ZlcnJpZGUgc3RhdGUgPSB7XG4gICAgZXJyb3I6IHVuZGVmaW5lZCxcbiAgfVxuXG4gIC8vIENvdW50IGhvdyBtYW55IGNvbXBvbmVudHMgZW5hYmxlZCByYXcgbW9kZSB0byBhdm9pZCBkaXNhYmxpbmdcbiAgLy8gcmF3IG1vZGUgdW50aWwgYWxsIGNvbXBvbmVudHMgZG9uJ3QgbmVlZCBpdCBhbnltb3JlXG4gIHJhd01vZGVFbmFibGVkQ291bnQgPSAwXG5cbiAgaW50ZXJuYWxfZXZlbnRFbWl0dGVyID0gbmV3IEV2ZW50RW1pdHRlcigpXG4gIGtleVBhcnNlU3RhdGUgPSBJTklUSUFMX1NUQVRFXG4gIC8vIFRpbWVyIGZvciBmbHVzaGluZyBpbmNvbXBsZXRlIGVzY2FwZSBzZXF1ZW5jZXNcbiAgaW5jb21wbGV0ZUVzY2FwZVRpbWVyOiBOb2RlSlMuVGltZW91dCB8IG51bGwgPSBudWxsXG4gIC8vIFRpbWVvdXQgZHVyYXRpb25zIGZvciBpbmNvbXBsZXRlIHNlcXVlbmNlcyAobXMpXG4gIHJlYWRvbmx5IE5PUk1BTF9USU1FT1VUID0gNTAgLy8gU2hvcnQgdGltZW91dCBmb3IgcmVndWxhciBlc2Mgc2VxdWVuY2VzXG4gIHJlYWRvbmx5IFBBU1RFX1RJTUVPVVQgPSA1MDAgLy8gTG9uZ2VyIHRpbWVvdXQgZm9yIHBhc3RlIG9wZXJhdGlvbnNcblxuICAvLyBUZXJtaW5hbCBxdWVyeS9yZXNwb25zZSBkaXNwYXRjaC4gUmVzcG9uc2VzIGFycml2ZSBvbiBzdGRpbiAocGFyc2VkXG4gIC8vIG91dCBieSBwYXJzZS1rZXlwcmVzcykgYW5kIGFyZSByb3V0ZWQgdG8gcGVuZGluZyBwcm9taXNlIHJlc29sdmVycy5cbiAgcXVlcmllciA9IG5ldyBUZXJtaW5hbFF1ZXJpZXIodGhpcy5wcm9wcy5zdGRvdXQpXG5cbiAgLy8gTXVsdGktY2xpY2sgdHJhY2tpbmcgZm9yIGRvdWJsZS90cmlwbGUtY2xpY2sgdGV4dCBzZWxlY3Rpb24uIEEgY2xpY2tcbiAgLy8gd2l0aGluIE1VTFRJX0NMSUNLX1RJTUVPVVRfTVMgYW5kIE1VTFRJX0NMSUNLX0RJU1RBTkNFIG9mIHRoZSBwcmV2aW91c1xuICAvLyBjbGljayBpbmNyZW1lbnRzIGNsaWNrQ291bnQ7IG90aGVyd2lzZSBpdCByZXNldHMgdG8gMS5cbiAgbGFzdENsaWNrVGltZSA9IDBcbiAgbGFzdENsaWNrQ29sID0gLTFcbiAgbGFzdENsaWNrUm93ID0gLTFcbiAgY2xpY2tDb3VudCA9IDBcbiAgLy8gRGVmZXJyZWQgaHlwZXJsaW5rLW9wZW4gdGltZXIg4oCUIGNhbmNlbGxlZCBpZiBhIHNlY29uZCBjbGljayBhcnJpdmVzXG4gIC8vIHdpdGhpbiBNVUxUSV9DTElDS19USU1FT1VUX01TIChzbyBkb3VibGUtY2xpY2tpbmcgYSBoeXBlcmxpbmsgc2VsZWN0c1xuICAvLyB0aGUgd29yZCB3aXRob3V0IGFsc28gb3BlbmluZyB0aGUgYnJvd3NlcikuIERPTSBvbkNsaWNrIGRpc3BhdGNoIGlzXG4gIC8vIE5PVCBkZWZlcnJlZCDigJQgaXQgcmV0dXJucyB0cnVlIGZyb20gb25DbGlja0F0IGFuZCBza2lwcyB0aGlzIHRpbWVyLlxuICBwZW5kaW5nSHlwZXJsaW5rVGltZXI6IFJldHVyblR5cGU8dHlwZW9mIHNldFRpbWVvdXQ+IHwgbnVsbCA9IG51bGxcbiAgLy8gTGFzdCBtb2RlLTEwMDMgbW90aW9uIHBvc2l0aW9uLiBUZXJtaW5hbHMgYWxyZWFkeSBkZWR1cGUgdG8gY2VsbFxuICAvLyBncmFudWxhcml0eSBidXQgdGhpcyBhbHNvIGxldHMgdXMgc2tpcCBkaXNwYXRjaEhvdmVyIGVudGlyZWx5IG9uXG4gIC8vIHJlcGVhdCBldmVudHMgKGRyYWctdGhlbi1yZWxlYXNlIGF0IHNhbWUgY2VsbCwgZXRjLikuXG4gIGxhc3RIb3ZlckNvbCA9IC0xXG4gIGxhc3RIb3ZlclJvdyA9IC0xXG5cbiAgLy8gVGltZXN0YW1wIG9mIGxhc3Qgc3RkaW4gY2h1bmsuIFVzZWQgdG8gZGV0ZWN0IGxvbmcgZ2FwcyAodG11eCBhdHRhY2gsXG4gIC8vIHNzaCByZWNvbm5lY3QsIGxhcHRvcCB3YWtlKSBhbmQgdHJpZ2dlciB0ZXJtaW5hbCBtb2RlIHJlLWFzc2VydC5cbiAgLy8gSW5pdGlhbGl6ZWQgdG8gbm93IHNvIHN0YXJ0dXAgZG9lc24ndCBmYWxzZS10cmlnZ2VyLlxuICBsYXN0U3RkaW5UaW1lID0gRGF0ZS5ub3coKVxuXG4gIC8vIERldGVybWluZXMgaWYgVFRZIGlzIHN1cHBvcnRlZCBvbiB0aGUgcHJvdmlkZWQgc3RkaW5cbiAgaXNSYXdNb2RlU3VwcG9ydGVkKCk6IGJvb2xlYW4ge1xuICAgIHJldHVybiB0aGlzLnByb3BzLnN0ZGluLmlzVFRZXG4gIH1cblxuICBvdmVycmlkZSByZW5kZXIoKSB7XG4gICAgcmV0dXJuIChcbiAgICAgIDxUZXJtaW5hbFNpemVDb250ZXh0LlByb3ZpZGVyXG4gICAgICAgIHZhbHVlPXt7XG4gICAgICAgICAgY29sdW1uczogdGhpcy5wcm9wcy50ZXJtaW5hbENvbHVtbnMsXG4gICAgICAgICAgcm93czogdGhpcy5wcm9wcy50ZXJtaW5hbFJvd3MsXG4gICAgICAgIH19XG4gICAgICA+XG4gICAgICAgIDxBcHBDb250ZXh0LlByb3ZpZGVyXG4gICAgICAgICAgdmFsdWU9e3tcbiAgICAgICAgICAgIGV4aXQ6IHRoaXMuaGFuZGxlRXhpdCxcbiAgICAgICAgICB9fVxuICAgICAgICA+XG4gICAgICAgICAgPFN0ZGluQ29udGV4dC5Qcm92aWRlclxuICAgICAgICAgICAgdmFsdWU9e3tcbiAgICAgICAgICAgICAgc3RkaW46IHRoaXMucHJvcHMuc3RkaW4sXG4gICAgICAgICAgICAgIHNldFJhd01vZGU6IHRoaXMuaGFuZGxlU2V0UmF3TW9kZSxcbiAgICAgICAgICAgICAgaXNSYXdNb2RlU3VwcG9ydGVkOiB0aGlzLmlzUmF3TW9kZVN1cHBvcnRlZCgpLFxuXG4gICAgICAgICAgICAgIGludGVybmFsX2V4aXRPbkN0cmxDOiB0aGlzLnByb3BzLmV4aXRPbkN0cmxDLFxuXG4gICAgICAgICAgICAgIGludGVybmFsX2V2ZW50RW1pdHRlcjogdGhpcy5pbnRlcm5hbF9ldmVudEVtaXR0ZXIsXG4gICAgICAgICAgICAgIGludGVybmFsX3F1ZXJpZXI6IHRoaXMucXVlcmllcixcbiAgICAgICAgICAgIH19XG4gICAgICAgICAgPlxuICAgICAgICAgICAgPFRlcm1pbmFsRm9jdXNQcm92aWRlcj5cbiAgICAgICAgICAgICAgPENsb2NrUHJvdmlkZXI+XG4gICAgICAgICAgICAgICAgPEN1cnNvckRlY2xhcmF0aW9uQ29udGV4dC5Qcm92aWRlclxuICAgICAgICAgICAgICAgICAgdmFsdWU9e3RoaXMucHJvcHMub25DdXJzb3JEZWNsYXJhdGlvbiA/PyAoKCkgPT4ge30pfVxuICAgICAgICAgICAgICAgID5cbiAgICAgICAgICAgICAgICAgIHt0aGlzLnN0YXRlLmVycm9yID8gKFxuICAgICAgICAgICAgICAgICAgICA8RXJyb3JPdmVydmlldyBlcnJvcj17dGhpcy5zdGF0ZS5lcnJvciBhcyBFcnJvcn0gLz5cbiAgICAgICAgICAgICAgICAgICkgOiAoXG4gICAgICAgICAgICAgICAgICAgIHRoaXMucHJvcHMuY2hpbGRyZW5cbiAgICAgICAgICAgICAgICAgICl9XG4gICAgICAgICAgICAgICAgPC9DdXJzb3JEZWNsYXJhdGlvbkNvbnRleHQuUHJvdmlkZXI+XG4gICAgICAgICAgICAgIDwvQ2xvY2tQcm92aWRlcj5cbiAgICAgICAgICAgIDwvVGVybWluYWxGb2N1c1Byb3ZpZGVyPlxuICAgICAgICAgIDwvU3RkaW5Db250ZXh0LlByb3ZpZGVyPlxuICAgICAgICA8L0FwcENvbnRleHQuUHJvdmlkZXI+XG4gICAgICA8L1Rlcm1pbmFsU2l6ZUNvbnRleHQuUHJvdmlkZXI+XG4gICAgKVxuICB9XG5cbiAgb3ZlcnJpZGUgY29tcG9uZW50RGlkTW91bnQoKSB7XG4gICAgLy8gSW4gYWNjZXNzaWJpbGl0eSBtb2RlLCBrZWVwIHRoZSBuYXRpdmUgY3Vyc29yIHZpc2libGUgZm9yIHNjcmVlbiBtYWduaWZpZXJzIGFuZCBvdGhlciB0b29sc1xuICAgIGlmIChcbiAgICAgIHRoaXMucHJvcHMuc3Rkb3V0LmlzVFRZICYmXG4gICAgICAhaXNFbnZUcnV0aHkocHJvY2Vzcy5lbnYuQ0xBVURFX0NPREVfQUNDRVNTSUJJTElUWSlcbiAgICApIHtcbiAgICAgIHRoaXMucHJvcHMuc3Rkb3V0LndyaXRlKEhJREVfQ1VSU09SKVxuICAgIH1cbiAgfVxuXG4gIG92ZXJyaWRlIGNvbXBvbmVudFdpbGxVbm1vdW50KCkge1xuICAgIGlmICh0aGlzLnByb3BzLnN0ZG91dC5pc1RUWSkge1xuICAgICAgdGhpcy5wcm9wcy5zdGRvdXQud3JpdGUoU0hPV19DVVJTT1IpXG4gICAgfVxuXG4gICAgLy8gQ2xlYXIgYW55IHBlbmRpbmcgdGltZXJzXG4gICAgaWYgKHRoaXMuaW5jb21wbGV0ZUVzY2FwZVRpbWVyKSB7XG4gICAgICBjbGVhclRpbWVvdXQodGhpcy5pbmNvbXBsZXRlRXNjYXBlVGltZXIpXG4gICAgICB0aGlzLmluY29tcGxldGVFc2NhcGVUaW1lciA9IG51bGxcbiAgICB9XG4gICAgaWYgKHRoaXMucGVuZGluZ0h5cGVybGlua1RpbWVyKSB7XG4gICAgICBjbGVhclRpbWVvdXQodGhpcy5wZW5kaW5nSHlwZXJsaW5rVGltZXIpXG4gICAgICB0aGlzLnBlbmRpbmdIeXBlcmxpbmtUaW1lciA9IG51bGxcbiAgICB9XG4gICAgLy8gaWdub3JlIGNhbGxpbmcgc2V0UmF3TW9kZSBvbiBhbiBoYW5kbGUgc3RkaW4gaXQgY2Fubm90IGJlIGNhbGxlZFxuICAgIGlmICh0aGlzLmlzUmF3TW9kZVN1cHBvcnRlZCgpKSB7XG4gICAgICB0aGlzLmhhbmRsZVNldFJhd01vZGUoZmFsc2UpXG4gICAgfVxuICB9XG5cbiAgb3ZlcnJpZGUgY29tcG9uZW50RGlkQ2F0Y2goZXJyb3I6IEVycm9yKSB7XG4gICAgdGhpcy5oYW5kbGVFeGl0KGVycm9yKVxuICB9XG5cbiAgaGFuZGxlU2V0UmF3TW9kZSA9IChpc0VuYWJsZWQ6IGJvb2xlYW4pOiB2b2lkID0+IHtcbiAgICBjb25zdCB7IHN0ZGluIH0gPSB0aGlzLnByb3BzXG5cbiAgICBpZiAoIXRoaXMuaXNSYXdNb2RlU3VwcG9ydGVkKCkpIHtcbiAgICAgIGlmIChzdGRpbiA9PT0gcHJvY2Vzcy5zdGRpbikge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoXG4gICAgICAgICAgJ1JhdyBtb2RlIGlzIG5vdCBzdXBwb3J0ZWQgb24gdGhlIGN1cnJlbnQgcHJvY2Vzcy5zdGRpbiwgd2hpY2ggSW5rIHVzZXMgYXMgaW5wdXQgc3RyZWFtIGJ5IGRlZmF1bHQuXFxuUmVhZCBhYm91dCBob3cgdG8gcHJldmVudCB0aGlzIGVycm9yIG9uIGh0dHBzOi8vZ2l0aHViLmNvbS92YWRpbWRlbWVkZXMvaW5rLyNpc3Jhd21vZGVzdXBwb3J0ZWQnLFxuICAgICAgICApXG4gICAgICB9IGVsc2Uge1xuICAgICAgICB0aHJvdyBuZXcgRXJyb3IoXG4gICAgICAgICAgJ1JhdyBtb2RlIGlzIG5vdCBzdXBwb3J0ZWQgb24gdGhlIHN0ZGluIHByb3ZpZGVkIHRvIEluay5cXG5SZWFkIGFib3V0IGhvdyB0byBwcmV2ZW50IHRoaXMgZXJyb3Igb24gaHR0cHM6Ly9naXRodWIuY29tL3ZhZGltZGVtZWRlcy9pbmsvI2lzcmF3bW9kZXN1cHBvcnRlZCcsXG4gICAgICAgIClcbiAgICAgIH1cbiAgICB9XG5cbiAgICBzdGRpbi5zZXRFbmNvZGluZygndXRmOCcpXG5cbiAgICBpZiAoaXNFbmFibGVkKSB7XG4gICAgICAvLyBFbnN1cmUgcmF3IG1vZGUgaXMgZW5hYmxlZCBvbmx5IG9uY2VcbiAgICAgIGlmICh0aGlzLnJhd01vZGVFbmFibGVkQ291bnQgPT09IDApIHtcbiAgICAgICAgLy8gU3RvcCBlYXJseSBpbnB1dCBjYXB0dXJlIHJpZ2h0IGJlZm9yZSB3ZSBhZGQgb3VyIG93biByZWFkYWJsZSBoYW5kbGVyLlxuICAgICAgICAvLyBCb3RoIHVzZSB0aGUgc2FtZSBzdGRpbiAncmVhZGFibGUnICsgcmVhZCgpIHBhdHRlcm4sIHNvIHRoZXkgY2FuJ3RcbiAgICAgICAgLy8gY29leGlzdCAtLSBvdXIgaGFuZGxlciB3b3VsZCBkcmFpbiBzdGRpbiBiZWZvcmUgSW5rJ3MgY2FuIHNlZSBpdC5cbiAgICAgICAgLy8gVGhlIGJ1ZmZlcmVkIHRleHQgaXMgcHJlc2VydmVkIGZvciBSRVBMLnRzeCB2aWEgY29uc3VtZUVhcmx5SW5wdXQoKS5cbiAgICAgICAgc3RvcENhcHR1cmluZ0Vhcmx5SW5wdXQoKVxuICAgICAgICBzdGRpbi5yZWYoKVxuICAgICAgICBzdGRpbi5zZXRSYXdNb2RlKHRydWUpXG4gICAgICAgIHN0ZGluLmFkZExpc3RlbmVyKCdyZWFkYWJsZScsIHRoaXMuaGFuZGxlUmVhZGFibGUpXG4gICAgICAgIC8vIEVuYWJsZSBicmFja2V0ZWQgcGFzdGUgbW9kZVxuICAgICAgICB0aGlzLnByb3BzLnN0ZG91dC53cml0ZShFQlApXG4gICAgICAgIC8vIEVuYWJsZSB0ZXJtaW5hbCBmb2N1cyByZXBvcnRpbmcgKERFQ1NFVCAxMDA0KVxuICAgICAgICB0aGlzLnByb3BzLnN0ZG91dC53cml0ZShFRkUpXG4gICAgICAgIC8vIEVuYWJsZSBleHRlbmRlZCBrZXkgcmVwb3J0aW5nIHNvIGN0cmwrc2hpZnQrPGxldHRlcj4gaXNcbiAgICAgICAgLy8gZGlzdGluZ3Vpc2hhYmxlIGZyb20gY3RybCs8bGV0dGVyPi4gV2Ugd3JpdGUgYm90aCB0aGUga2l0dHkgc3RhY2tcbiAgICAgICAgLy8gcHVzaCAoQ1NJID4xdSkgYW5kIHh0ZXJtIG1vZGlmeU90aGVyS2V5cyBsZXZlbCAyIChDU0kgPjQ7Mm0pIOKAlFxuICAgICAgICAvLyB0ZXJtaW5hbHMgaG9ub3Igd2hpY2hldmVyIHRoZXkgaW1wbGVtZW50ICh0bXV4IG9ubHkgYWNjZXB0cyB0aGVcbiAgICAgICAgLy8gbGF0dGVyKS5cbiAgICAgICAgaWYgKHN1cHBvcnRzRXh0ZW5kZWRLZXlzKCkpIHtcbiAgICAgICAgICB0aGlzLnByb3BzLnN0ZG91dC53cml0ZShFTkFCTEVfS0lUVFlfS0VZQk9BUkQpXG4gICAgICAgICAgdGhpcy5wcm9wcy5zdGRvdXQud3JpdGUoRU5BQkxFX01PRElGWV9PVEhFUl9LRVlTKVxuICAgICAgICB9XG4gICAgICAgIC8vIFByb2JlIHRlcm1pbmFsIGlkZW50aXR5LiBYVFZFUlNJT04gc3Vydml2ZXMgU1NIIChxdWVyeS9yZXBseSBnb2VzXG4gICAgICAgIC8vIHRocm91Z2ggdGhlIHB0eSksIHVubGlrZSBURVJNX1BST0dSQU0uIFVzZWQgZm9yIHdoZWVsLXNjcm9sbCBiYXNlXG4gICAgICAgIC8vIGRldGVjdGlvbiB3aGVuIGVudiB2YXJzIGFyZSBhYnNlbnQuIEZpcmUtYW5kLWZvcmdldDogdGhlIERBMVxuICAgICAgICAvLyBzZW50aW5lbCBib3VuZHMgdGhlIHJvdW5kLXRyaXAsIGFuZCBpZiB0aGUgdGVybWluYWwgaWdub3JlcyB0aGVcbiAgICAgICAgLy8gcXVlcnksIGZsdXNoKCkgc3RpbGwgcmVzb2x2ZXMgYW5kIG5hbWUgc3RheXMgdW5kZWZpbmVkLlxuICAgICAgICAvLyBEZWZlcnJlZCB0byBuZXh0IHRpY2sgc28gaXQgZmlyZXMgQUZURVIgdGhlIGN1cnJlbnQgc3luY2hyb25vdXNcbiAgICAgICAgLy8gaW5pdCBzZXF1ZW5jZSBjb21wbGV0ZXMg4oCUIGF2b2lkcyBpbnRlcmxlYXZpbmcgd2l0aCBhbHQtc2NyZWVuL21vdXNlXG4gICAgICAgIC8vIHRyYWNraW5nIGVuYWJsZSB3cml0ZXMgdGhhdCBtYXkgaGFwcGVuIGluIHRoZSBzYW1lIHJlbmRlciBjeWNsZS5cbiAgICAgICAgc2V0SW1tZWRpYXRlKCgpID0+IHtcbiAgICAgICAgICB2b2lkIFByb21pc2UuYWxsKFtcbiAgICAgICAgICAgIHRoaXMucXVlcmllci5zZW5kKHh0dmVyc2lvbigpKSxcbiAgICAgICAgICAgIHRoaXMucXVlcmllci5mbHVzaCgpLFxuICAgICAgICAgIF0pLnRoZW4oKFtyXSkgPT4ge1xuICAgICAgICAgICAgaWYgKHIpIHtcbiAgICAgICAgICAgICAgc2V0WHR2ZXJzaW9uTmFtZShyLm5hbWUpXG4gICAgICAgICAgICAgIGxvZ0ZvckRlYnVnZ2luZyhgWFRWRVJTSU9OOiB0ZXJtaW5hbCBpZGVudGlmaWVkIGFzIFwiJHtyLm5hbWV9XCJgKVxuICAgICAgICAgICAgfSBlbHNlIHtcbiAgICAgICAgICAgICAgbG9nRm9yRGVidWdnaW5nKCdYVFZFUlNJT046IG5vIHJlcGx5ICh0ZXJtaW5hbCBpZ25vcmVkIHF1ZXJ5KScpXG4gICAgICAgICAgICB9XG4gICAgICAgICAgfSlcbiAgICAgICAgfSlcbiAgICAgIH1cblxuICAgICAgdGhpcy5yYXdNb2RlRW5hYmxlZENvdW50KytcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIC8vIERpc2FibGUgcmF3IG1vZGUgb25seSB3aGVuIG5vIGNvbXBvbmVudHMgbGVmdCB0aGF0IGFyZSB1c2luZyBpdFxuICAgIGlmICgtLXRoaXMucmF3TW9kZUVuYWJsZWRDb3VudCA9PT0gMCkge1xuICAgICAgdGhpcy5wcm9wcy5zdGRvdXQud3JpdGUoRElTQUJMRV9NT0RJRllfT1RIRVJfS0VZUylcbiAgICAgIHRoaXMucHJvcHMuc3Rkb3V0LndyaXRlKERJU0FCTEVfS0lUVFlfS0VZQk9BUkQpXG4gICAgICAvLyBEaXNhYmxlIHRlcm1pbmFsIGZvY3VzIHJlcG9ydGluZyAoREVDU0VUIDEwMDQpXG4gICAgICB0aGlzLnByb3BzLnN0ZG91dC53cml0ZShERkUpXG4gICAgICAvLyBEaXNhYmxlIGJyYWNrZXRlZCBwYXN0ZSBtb2RlXG4gICAgICB0aGlzLnByb3BzLnN0ZG91dC53cml0ZShEQlApXG4gICAgICBzdGRpbi5zZXRSYXdNb2RlKGZhbHNlKVxuICAgICAgc3RkaW4ucmVtb3ZlTGlzdGVuZXIoJ3JlYWRhYmxlJywgdGhpcy5oYW5kbGVSZWFkYWJsZSlcbiAgICAgIHN0ZGluLnVucmVmKClcbiAgICB9XG4gIH1cblxuICAvLyBIZWxwZXIgdG8gZmx1c2ggaW5jb21wbGV0ZSBlc2NhcGUgc2VxdWVuY2VzXG4gIGZsdXNoSW5jb21wbGV0ZSA9ICgpOiB2b2lkID0+IHtcbiAgICAvLyBDbGVhciB0aGUgdGltZXIgcmVmZXJlbmNlXG4gICAgdGhpcy5pbmNvbXBsZXRlRXNjYXBlVGltZXIgPSBudWxsXG5cbiAgICAvLyBPbmx5IHByb2NlZWQgaWYgd2UgaGF2ZSBpbmNvbXBsZXRlIHNlcXVlbmNlc1xuICAgIGlmICghdGhpcy5rZXlQYXJzZVN0YXRlLmluY29tcGxldGUpIHJldHVyblxuXG4gICAgLy8gRnVsbHNjcmVlbjogaWYgc3RkaW4gaGFzIGRhdGEgd2FpdGluZywgaXQncyBhbG1vc3QgY2VydGFpbmx5IHRoZVxuICAgIC8vIGNvbnRpbnVhdGlvbiBvZiB0aGUgYnVmZmVyZWQgc2VxdWVuY2UgKGUuZy4gYFs8NjQ7NzQ7MTZNYCBhZnRlciBhXG4gICAgLy8gbG9uZSBFU0MpLiBOb2RlJ3MgZXZlbnQgbG9vcCBydW5zIHRoZSB0aW1lcnMgcGhhc2UgYmVmb3JlIHRoZSBwb2xsXG4gICAgLy8gcGhhc2UsIHNvIHdoZW4gYSBoZWF2eSByZW5kZXIgYmxvY2tzIHRoZSBsb29wIHBhc3QgNTBtcywgdGhpcyB0aW1lclxuICAgIC8vIGZpcmVzIGJlZm9yZSB0aGUgcXVldWVkIHJlYWRhYmxlIGV2ZW50IGV2ZW4gdGhvdWdoIHRoZSBieXRlcyBhcmVcbiAgICAvLyBhbHJlYWR5IGJ1ZmZlcmVkLiBSZS1hcm0gaW5zdGVhZCBvZiBmbHVzaGluZzogaGFuZGxlUmVhZGFibGUgd2lsbFxuICAgIC8vIGRyYWluIHN0ZGluIG5leHQgYW5kIGNsZWFyIHRoaXMgdGltZXIuIFByZXZlbnRzIGJvdGggdGhlIHNwdXJpb3VzXG4gICAgLy8gRXNjYXBlIGtleSBhbmQgdGhlIGxvc3Qgc2Nyb2xsIGV2ZW50LlxuICAgIGlmICh0aGlzLnByb3BzLnN0ZGluLnJlYWRhYmxlTGVuZ3RoID4gMCkge1xuICAgICAgdGhpcy5pbmNvbXBsZXRlRXNjYXBlVGltZXIgPSBzZXRUaW1lb3V0KFxuICAgICAgICB0aGlzLmZsdXNoSW5jb21wbGV0ZSxcbiAgICAgICAgdGhpcy5OT1JNQUxfVElNRU9VVCxcbiAgICAgIClcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIC8vIFByb2Nlc3MgaW5jb21wbGV0ZSBhcyBhIGZsdXNoIG9wZXJhdGlvbiAoaW5wdXQ9bnVsbClcbiAgICAvLyBUaGlzIHJldXNlcyBhbGwgZXhpc3RpbmcgcGFyc2luZyBsb2dpY1xuICAgIHRoaXMucHJvY2Vzc0lucHV0KG51bGwpXG4gIH1cblxuICAvLyBQcm9jZXNzIGlucHV0IHRocm91Z2ggdGhlIHBhcnNlciBhbmQgaGFuZGxlIHRoZSByZXN1bHRzXG4gIHByb2Nlc3NJbnB1dCA9IChpbnB1dDogc3RyaW5nIHwgQnVmZmVyIHwgbnVsbCk6IHZvaWQgPT4ge1xuICAgIC8vIFBhcnNlIGlucHV0IHVzaW5nIG91ciBzdGF0ZSBtYWNoaW5lXG4gICAgY29uc3QgW2tleXMsIG5ld1N0YXRlXSA9IHBhcnNlTXVsdGlwbGVLZXlwcmVzc2VzKHRoaXMua2V5UGFyc2VTdGF0ZSwgaW5wdXQpXG4gICAgdGhpcy5rZXlQYXJzZVN0YXRlID0gbmV3U3RhdGVcblxuICAgIC8vIFByb2Nlc3MgQUxMIGtleXMgaW4gYSBTSU5HTEUgZGlzY3JldGVVcGRhdGVzIGNhbGwgdG8gcHJldmVudFxuICAgIC8vIFwiTWF4aW11bSB1cGRhdGUgZGVwdGggZXhjZWVkZWRcIiBlcnJvciB3aGVuIG1hbnkga2V5cyBhcnJpdmUgYXQgb25jZVxuICAgIC8vIChlLmcuLCBmcm9tIHBhc3RlIG9wZXJhdGlvbnMgb3IgaG9sZGluZyBrZXlzIHJhcGlkbHkpLlxuICAgIC8vIFRoaXMgYmF0Y2hlcyBhbGwgc3RhdGUgdXBkYXRlcyBmcm9tIGhhbmRsZUlucHV0IGFuZCBhbGwgdXNlSW5wdXRcbiAgICAvLyBsaXN0ZW5lcnMgdG9nZXRoZXIgd2l0aGluIG9uZSBoaWdoLXByaW9yaXR5IHVwZGF0ZSBjb250ZXh0LlxuICAgIGlmIChrZXlzLmxlbmd0aCA+IDApIHtcbiAgICAgIHJlY29uY2lsZXIuZGlzY3JldGVVcGRhdGVzKFxuICAgICAgICBwcm9jZXNzS2V5c0luQmF0Y2gsXG4gICAgICAgIHRoaXMsXG4gICAgICAgIGtleXMsXG4gICAgICAgIHVuZGVmaW5lZCxcbiAgICAgICAgdW5kZWZpbmVkLFxuICAgICAgKVxuICAgIH1cblxuICAgIC8vIElmIHdlIGhhdmUgaW5jb21wbGV0ZSBlc2NhcGUgc2VxdWVuY2VzLCBzZXQgYSB0aW1lciB0byBmbHVzaCB0aGVtXG4gICAgaWYgKHRoaXMua2V5UGFyc2VTdGF0ZS5pbmNvbXBsZXRlKSB7XG4gICAgICAvLyBDYW5jZWwgYW55IGV4aXN0aW5nIHRpbWVyIGZpcnN0XG4gICAgICBpZiAodGhpcy5pbmNvbXBsZXRlRXNjYXBlVGltZXIpIHtcbiAgICAgICAgY2xlYXJUaW1lb3V0KHRoaXMuaW5jb21wbGV0ZUVzY2FwZVRpbWVyKVxuICAgICAgfVxuICAgICAgdGhpcy5pbmNvbXBsZXRlRXNjYXBlVGltZXIgPSBzZXRUaW1lb3V0KFxuICAgICAgICB0aGlzLmZsdXNoSW5jb21wbGV0ZSxcbiAgICAgICAgdGhpcy5rZXlQYXJzZVN0YXRlLm1vZGUgPT09ICdJTl9QQVNURSdcbiAgICAgICAgICA/IHRoaXMuUEFTVEVfVElNRU9VVFxuICAgICAgICAgIDogdGhpcy5OT1JNQUxfVElNRU9VVCxcbiAgICAgIClcbiAgICB9XG4gIH1cblxuICBoYW5kbGVSZWFkYWJsZSA9ICgpOiB2b2lkID0+IHtcbiAgICAvLyBEZXRlY3QgbG9uZyBzdGRpbiBnYXBzICh0bXV4IGF0dGFjaCwgc3NoIHJlY29ubmVjdCwgbGFwdG9wIHdha2UpLlxuICAgIC8vIFRoZSB0ZXJtaW5hbCBtYXkgaGF2ZSByZXNldCBERUMgcHJpdmF0ZSBtb2RlczsgcmUtYXNzZXJ0IG1vdXNlXG4gICAgLy8gdHJhY2tpbmcuIENoZWNrZWQgYmVmb3JlIHRoZSByZWFkIGxvb3Agc28gb25lIERhdGUubm93KCkgY292ZXJzXG4gICAgLy8gYWxsIGNodW5rcyBpbiB0aGlzIHJlYWRhYmxlIGV2ZW50LlxuICAgIGNvbnN0IG5vdyA9IERhdGUubm93KClcbiAgICBpZiAobm93IC0gdGhpcy5sYXN0U3RkaW5UaW1lID4gU1RESU5fUkVTVU1FX0dBUF9NUykge1xuICAgICAgdGhpcy5wcm9wcy5vblN0ZGluUmVzdW1lPy4oKVxuICAgIH1cbiAgICB0aGlzLmxhc3RTdGRpblRpbWUgPSBub3dcbiAgICB0cnkge1xuICAgICAgbGV0IGNodW5rXG4gICAgICB3aGlsZSAoKGNodW5rID0gdGhpcy5wcm9wcy5zdGRpbi5yZWFkKCkgYXMgc3RyaW5nIHwgbnVsbCkgIT09IG51bGwpIHtcbiAgICAgICAgLy8gUHJvY2VzcyB0aGUgaW5wdXQgY2h1bmtcbiAgICAgICAgdGhpcy5wcm9jZXNzSW5wdXQoY2h1bmspXG4gICAgICB9XG4gICAgfSBjYXRjaCAoZXJyb3IpIHtcbiAgICAgIC8vIEluIEJ1biwgYW4gdW5jYXVnaHQgdGhyb3cgaW5zaWRlIGEgc3RyZWFtICdyZWFkYWJsZScgaGFuZGxlciBjYW5cbiAgICAgIC8vIHBlcm1hbmVudGx5IHdlZGdlIHRoZSBzdHJlYW06IGRhdGEgc3RheXMgYnVmZmVyZWQgYW5kICdyZWFkYWJsZSdcbiAgICAgIC8vIG5ldmVyIHJlLWVtaXRzLiBDYXRjaGluZyBoZXJlIGVuc3VyZXMgdGhlIHN0cmVhbSBzdGF5cyBoZWFsdGh5IHNvXG4gICAgICAvLyBzdWJzZXF1ZW50IGtleXN0cm9rZXMgYXJlIHN0aWxsIGRlbGl2ZXJlZC5cbiAgICAgIGxvZ0Vycm9yKGVycm9yKVxuXG4gICAgICAvLyBSZS1hdHRhY2ggdGhlIGxpc3RlbmVyIGluIGNhc2UgdGhlIGV4Y2VwdGlvbiBkZXRhY2hlZCBpdC5cbiAgICAgIC8vIEJ1biBtYXkgcmVtb3ZlIHRoZSBsaXN0ZW5lciBhZnRlciBhbiBlcnJvcjsgd2l0aG91dCB0aGlzLFxuICAgICAgLy8gdGhlIHNlc3Npb24gZnJlZXplcyBwZXJtYW5lbnRseSAoc3RkaW4gcmVhZGVyIGRlYWQsIGV2ZW50IGxvb3AgYWxpdmUpLlxuICAgICAgY29uc3QgeyBzdGRpbiB9ID0gdGhpcy5wcm9wc1xuICAgICAgaWYgKFxuICAgICAgICB0aGlzLnJhd01vZGVFbmFibGVkQ291bnQgPiAwICYmXG4gICAgICAgICFzdGRpbi5saXN0ZW5lcnMoJ3JlYWRhYmxlJykuaW5jbHVkZXModGhpcy5oYW5kbGVSZWFkYWJsZSlcbiAgICAgICkge1xuICAgICAgICBsb2dGb3JEZWJ1Z2dpbmcoXG4gICAgICAgICAgJ2hhbmRsZVJlYWRhYmxlOiByZS1hdHRhY2hpbmcgc3RkaW4gcmVhZGFibGUgbGlzdGVuZXIgYWZ0ZXIgZXJyb3IgcmVjb3ZlcnknLFxuICAgICAgICAgIHsgbGV2ZWw6ICd3YXJuJyB9LFxuICAgICAgICApXG4gICAgICAgIHN0ZGluLmFkZExpc3RlbmVyKCdyZWFkYWJsZScsIHRoaXMuaGFuZGxlUmVhZGFibGUpXG4gICAgICB9XG4gICAgfVxuICB9XG5cbiAgaGFuZGxlSW5wdXQgPSAoaW5wdXQ6IHN0cmluZyB8IHVuZGVmaW5lZCk6IHZvaWQgPT4ge1xuICAgIC8vIEV4aXQgb24gQ3RybCtDXG4gICAgaWYgKGlucHV0ID09PSAnXFx4MDMnICYmIHRoaXMucHJvcHMuZXhpdE9uQ3RybEMpIHtcbiAgICAgIHRoaXMuaGFuZGxlRXhpdCgpXG4gICAgfVxuXG4gICAgLy8gTm90ZTogQ3RybCtaIChzdXNwZW5kKSBpcyBub3cgaGFuZGxlZCBpbiBwcm9jZXNzS2V5c0luQmF0Y2ggdXNpbmcgdGhlXG4gICAgLy8gcGFyc2VkIGtleSB0byBzdXBwb3J0IGJvdGggcmF3IChcXHgxYSkgYW5kIENTSSB1IGZvcm1hdCBmcm9tIEtpdHR5XG4gICAgLy8ga2V5Ym9hcmQgcHJvdG9jb2wgdGVybWluYWxzIChHaG9zdHR5LCBpVGVybTIsIGtpdHR5LCBXZXpUZXJtKVxuICB9XG5cbiAgaGFuZGxlRXhpdCA9IChlcnJvcj86IEVycm9yKTogdm9pZCA9PiB7XG4gICAgaWYgKHRoaXMuaXNSYXdNb2RlU3VwcG9ydGVkKCkpIHtcbiAgICAgIHRoaXMuaGFuZGxlU2V0UmF3TW9kZShmYWxzZSlcbiAgICB9XG5cbiAgICB0aGlzLnByb3BzLm9uRXhpdChlcnJvcilcbiAgfVxuXG4gIGhhbmRsZVRlcm1pbmFsRm9jdXMgPSAoaXNGb2N1c2VkOiBib29sZWFuKTogdm9pZCA9PiB7XG4gICAgLy8gc2V0VGVybWluYWxGb2N1c2VkIG5vdGlmaWVzIHN1YnNjcmliZXJzOiBUZXJtaW5hbEZvY3VzUHJvdmlkZXIgKGNvbnRleHQpXG4gICAgLy8gYW5kIENsb2NrIChpbnRlcnZhbCBzcGVlZCkg4oCUIG5vIEFwcCBzZXRTdGF0ZSBuZWVkZWQuXG4gICAgc2V0VGVybWluYWxGb2N1c2VkKGlzRm9jdXNlZClcbiAgfVxuXG4gIGhhbmRsZVN1c3BlbmQgPSAoKTogdm9pZCA9PiB7XG4gICAgaWYgKCF0aGlzLmlzUmF3TW9kZVN1cHBvcnRlZCgpKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG5cbiAgICAvLyBTdG9yZSB0aGUgZXhhY3QgcmF3IG1vZGUgY291bnQgdG8gcmVzdG9yZSBpdCBwcm9wZXJseVxuICAgIGNvbnN0IHJhd01vZGVDb3VudEJlZm9yZVN1c3BlbmQgPSB0aGlzLnJhd01vZGVFbmFibGVkQ291bnRcblxuICAgIC8vIENvbXBsZXRlbHkgZGlzYWJsZSByYXcgbW9kZSBiZWZvcmUgc3VzcGVuZGluZ1xuICAgIHdoaWxlICh0aGlzLnJhd01vZGVFbmFibGVkQ291bnQgPiAwKSB7XG4gICAgICB0aGlzLmhhbmRsZVNldFJhd01vZGUoZmFsc2UpXG4gICAgfVxuXG4gICAgLy8gU2hvdyBjdXJzb3IsIGRpc2FibGUgZm9jdXMgcmVwb3J0aW5nLCBhbmQgZGlzYWJsZSBtb3VzZSB0cmFja2luZ1xuICAgIC8vIGJlZm9yZSBzdXNwZW5kaW5nLiBESVNBQkxFX01PVVNFX1RSQUNLSU5HIGlzIGEgbm8tb3AgaWYgdHJhY2tpbmdcbiAgICAvLyB3YXNuJ3QgZW5hYmxlZCwgc28gaXQncyBzYWZlIHRvIGVtaXQgdW5jb25kaXRpb25hbGx5IOKAlCB3aXRob3V0XG4gICAgLy8gaXQsIFNHUiBtb3VzZSBzZXF1ZW5jZXMgd291bGQgYXBwZWFyIGFzIGdhcmJsZWQgdGV4dCBhdCB0aGVcbiAgICAvLyBzaGVsbCBwcm9tcHQgd2hpbGUgc3VzcGVuZGVkLlxuICAgIGlmICh0aGlzLnByb3BzLnN0ZG91dC5pc1RUWSkge1xuICAgICAgdGhpcy5wcm9wcy5zdGRvdXQud3JpdGUoU0hPV19DVVJTT1IgKyBERkUgKyBESVNBQkxFX01PVVNFX1RSQUNLSU5HKVxuICAgIH1cblxuICAgIC8vIEVtaXQgc3VzcGVuZCBldmVudCBmb3IgQ2xhdWRlIENvZGUgdG8gaGFuZGxlLiBNb3N0bHkganVzdCBoYXMgYSBub3RpZmljYXRpb25cbiAgICB0aGlzLmludGVybmFsX2V2ZW50RW1pdHRlci5lbWl0KCdzdXNwZW5kJylcblxuICAgIC8vIFNldCB1cCByZXN1bWUgaGFuZGxlclxuICAgIGNvbnN0IHJlc3VtZUhhbmRsZXIgPSAoKSA9PiB7XG4gICAgICAvLyBSZXN0b3JlIHJhdyBtb2RlIHRvIGV4YWN0IHByZXZpb3VzIHN0YXRlXG4gICAgICBmb3IgKGxldCBpID0gMDsgaSA8IHJhd01vZGVDb3VudEJlZm9yZVN1c3BlbmQ7IGkrKykge1xuICAgICAgICBpZiAodGhpcy5pc1Jhd01vZGVTdXBwb3J0ZWQoKSkge1xuICAgICAgICAgIHRoaXMuaGFuZGxlU2V0UmF3TW9kZSh0cnVlKVxuICAgICAgICB9XG4gICAgICB9XG5cbiAgICAgIC8vIEhpZGUgY3Vyc29yICh1bmxlc3MgaW4gYWNjZXNzaWJpbGl0eSBtb2RlKSBhbmQgcmUtZW5hYmxlIGZvY3VzIHJlcG9ydGluZyBhZnRlciByZXN1bWluZ1xuICAgICAgaWYgKHRoaXMucHJvcHMuc3Rkb3V0LmlzVFRZKSB7XG4gICAgICAgIGlmICghaXNFbnZUcnV0aHkocHJvY2Vzcy5lbnYuQ0xBVURFX0NPREVfQUNDRVNTSUJJTElUWSkpIHtcbiAgICAgICAgICB0aGlzLnByb3BzLnN0ZG91dC53cml0ZShISURFX0NVUlNPUilcbiAgICAgICAgfVxuICAgICAgICAvLyBSZS1lbmFibGUgZm9jdXMgcmVwb3J0aW5nIHRvIHJlc3RvcmUgdGVybWluYWwgc3RhdGVcbiAgICAgICAgdGhpcy5wcm9wcy5zdGRvdXQud3JpdGUoRUZFKVxuICAgICAgfVxuXG4gICAgICAvLyBFbWl0IHJlc3VtZSBldmVudCBmb3IgQ2xhdWRlIENvZGUgdG8gaGFuZGxlXG4gICAgICB0aGlzLmludGVybmFsX2V2ZW50RW1pdHRlci5lbWl0KCdyZXN1bWUnKVxuXG4gICAgICBwcm9jZXNzLnJlbW92ZUxpc3RlbmVyKCdTSUdDT05UJywgcmVzdW1lSGFuZGxlcilcbiAgICB9XG5cbiAgICBwcm9jZXNzLm9uKCdTSUdDT05UJywgcmVzdW1lSGFuZGxlcilcbiAgICBwcm9jZXNzLmtpbGwocHJvY2Vzcy5waWQsICdTSUdTVE9QJylcbiAgfVxufVxuXG4vLyBIZWxwZXIgdG8gcHJvY2VzcyBhbGwga2V5cyB3aXRoaW4gYSBzaW5nbGUgZGlzY3JldGUgdXBkYXRlIGNvbnRleHQuXG4vLyBkaXNjcmV0ZVVwZGF0ZXMgZXhwZWN0cyAoZm4sIGEsIGIsIGMsIGQpIC0+IGZuKGEsIGIsIGMsIGQpXG5mdW5jdGlvbiBwcm9jZXNzS2V5c0luQmF0Y2goXG4gIGFwcDogQXBwLFxuICBpdGVtczogUGFyc2VkSW5wdXRbXSxcbiAgX3VudXNlZDE6IHVuZGVmaW5lZCxcbiAgX3VudXNlZDI6IHVuZGVmaW5lZCxcbik6IHZvaWQge1xuICAvLyBVcGRhdGUgaW50ZXJhY3Rpb24gdGltZSBmb3Igbm90aWZpY2F0aW9uIHRpbWVvdXQgdHJhY2tpbmcuXG4gIC8vIFRoaXMgaXMgY2FsbGVkIGZyb20gdGhlIGNlbnRyYWwgaW5wdXQgaGFuZGxlciB0byBhdm9pZCBoYXZpbmcgbXVsdGlwbGVcbiAgLy8gc3RkaW4gbGlzdGVuZXJzIHRoYXQgY2FuIGNhdXNlIHJhY2UgY29uZGl0aW9ucyBhbmQgZHJvcHBlZCBpbnB1dC5cbiAgLy8gVGVybWluYWwgcmVzcG9uc2VzIChraW5kOiAncmVzcG9uc2UnKSBhcmUgYXV0b21hdGVkLCBub3QgdXNlciBpbnB1dC5cbiAgLy8gTW9kZS0xMDAzIG5vLWJ1dHRvbiBtb3Rpb24gaXMgYWxzbyBleGNsdWRlZCDigJQgcGFzc2l2ZSBjdXJzb3IgZHJpZnQgaXNcbiAgLy8gbm90IGVuZ2FnZW1lbnQgKHdvdWxkIHN1cHByZXNzIGlkbGUgbm90aWZpY2F0aW9ucyArIGRlZmVyIGhvdXNla2VlcGluZykuXG4gIGlmIChcbiAgICBpdGVtcy5zb21lKFxuICAgICAgaSA9PlxuICAgICAgICBpLmtpbmQgPT09ICdrZXknIHx8XG4gICAgICAgIChpLmtpbmQgPT09ICdtb3VzZScgJiZcbiAgICAgICAgICAhKChpLmJ1dHRvbiAmIDB4MjApICE9PSAwICYmIChpLmJ1dHRvbiAmIDB4MDMpID09PSAzKSksXG4gICAgKVxuICApIHtcbiAgICB1cGRhdGVMYXN0SW50ZXJhY3Rpb25UaW1lKClcbiAgfVxuXG4gIGZvciAoY29uc3QgaXRlbSBvZiBpdGVtcykge1xuICAgIC8vIFRlcm1pbmFsIHJlc3BvbnNlcyAoREVDUlBNLCBEQTEsIE9TQyByZXBsaWVzLCBldGMuKSBhcmUgbm90IHVzZXJcbiAgICAvLyBpbnB1dCDigJQgcm91dGUgdGhlbSB0byB0aGUgcXVlcmllciB0byByZXNvbHZlIHBlbmRpbmcgcHJvbWlzZXMuXG4gICAgaWYgKGl0ZW0ua2luZCA9PT0gJ3Jlc3BvbnNlJykge1xuICAgICAgYXBwLnF1ZXJpZXIub25SZXNwb25zZShpdGVtLnJlc3BvbnNlKVxuICAgICAgY29udGludWVcbiAgICB9XG5cbiAgICAvLyBNb3VzZSBjbGljay9kcmFnIGV2ZW50cyB1cGRhdGUgc2VsZWN0aW9uIHN0YXRlIChmdWxsc2NyZWVuIG9ubHkpLlxuICAgIC8vIFRlcm1pbmFsIHNlbmRzIDEtaW5kZXhlZCBjb2wvcm93OyBjb252ZXJ0IHRvIDAtaW5kZXhlZCBmb3IgdGhlXG4gICAgLy8gc2NyZWVuIGJ1ZmZlci4gQnV0dG9uIGJpdCAweDIwID0gZHJhZyAobW90aW9uIHdoaWxlIGJ1dHRvbiBoZWxkKS5cbiAgICBpZiAoaXRlbS5raW5kID09PSAnbW91c2UnKSB7XG4gICAgICBoYW5kbGVNb3VzZUV2ZW50KGFwcCwgaXRlbSlcbiAgICAgIGNvbnRpbnVlXG4gICAgfVxuXG4gICAgY29uc3Qgc2VxdWVuY2UgPSBpdGVtLnNlcXVlbmNlXG5cbiAgICAvLyBIYW5kbGUgdGVybWluYWwgZm9jdXMgZXZlbnRzIChERUNTRVQgMTAwNClcbiAgICBpZiAoc2VxdWVuY2UgPT09IEZPQ1VTX0lOKSB7XG4gICAgICBhcHAuaGFuZGxlVGVybWluYWxGb2N1cyh0cnVlKVxuICAgICAgY29uc3QgZXZlbnQgPSBuZXcgVGVybWluYWxGb2N1c0V2ZW50KCd0ZXJtaW5hbGZvY3VzJylcbiAgICAgIGFwcC5pbnRlcm5hbF9ldmVudEVtaXR0ZXIuZW1pdCgndGVybWluYWxmb2N1cycsIGV2ZW50KVxuICAgICAgY29udGludWVcbiAgICB9XG4gICAgaWYgKHNlcXVlbmNlID09PSBGT0NVU19PVVQpIHtcbiAgICAgIGFwcC5oYW5kbGVUZXJtaW5hbEZvY3VzKGZhbHNlKVxuICAgICAgLy8gRGVmZW5zaXZlOiBpZiB3ZSBsb3N0IHRoZSByZWxlYXNlIGV2ZW50IChtb3VzZSByZWxlYXNlZCBvdXRzaWRlXG4gICAgICAvLyB0ZXJtaW5hbCB3aW5kb3cg4oCUIHNvbWUgZW11bGF0b3JzIGRyb3AgaXQgcmF0aGVyIHRoYW4gY2FwdHVyaW5nIHRoZVxuICAgICAgLy8gcG9pbnRlciksIGZvY3VzLW91dCBpcyB0aGUgbmV4dCBvYnNlcnZhYmxlIHNpZ25hbCB0aGF0IHRoZSBkcmFnIGlzXG4gICAgICAvLyBvdmVyLiBXaXRob3V0IHRoaXMsIGRyYWctdG8tc2Nyb2xsJ3MgdGltZXIgcnVucyB1bnRpbCB0aGUgc2Nyb2xsXG4gICAgICAvLyBib3VuZGFyeSBpcyBoaXQuXG4gICAgICBpZiAoYXBwLnByb3BzLnNlbGVjdGlvbi5pc0RyYWdnaW5nKSB7XG4gICAgICAgIGZpbmlzaFNlbGVjdGlvbihhcHAucHJvcHMuc2VsZWN0aW9uKVxuICAgICAgICBhcHAucHJvcHMub25TZWxlY3Rpb25DaGFuZ2UoKVxuICAgICAgfVxuICAgICAgY29uc3QgZXZlbnQgPSBuZXcgVGVybWluYWxGb2N1c0V2ZW50KCd0ZXJtaW5hbGJsdXInKVxuICAgICAgYXBwLmludGVybmFsX2V2ZW50RW1pdHRlci5lbWl0KCd0ZXJtaW5hbGJsdXInLCBldmVudClcbiAgICAgIGNvbnRpbnVlXG4gICAgfVxuXG4gICAgLy8gRmFpbHNhZmU6IGlmIHdlIHJlY2VpdmUgaW5wdXQsIHRoZSB0ZXJtaW5hbCBtdXN0IGJlIGZvY3VzZWRcbiAgICBpZiAoIWdldFRlcm1pbmFsRm9jdXNlZCgpKSB7XG4gICAgICBzZXRUZXJtaW5hbEZvY3VzZWQodHJ1ZSlcbiAgICB9XG5cbiAgICAvLyBIYW5kbGUgQ3RybCtaIChzdXNwZW5kKSB1c2luZyBwYXJzZWQga2V5IHRvIHN1cHBvcnQgYm90aCByYXcgKFxceDFhKSBhbmRcbiAgICAvLyBDU0kgdSBmb3JtYXQgKFxceDFiWzEyMjs1dSkgZnJvbSBLaXR0eSBrZXlib2FyZCBwcm90b2NvbCB0ZXJtaW5hbHNcbiAgICBpZiAoaXRlbS5uYW1lID09PSAneicgJiYgaXRlbS5jdHJsICYmIFNVUFBPUlRTX1NVU1BFTkQpIHtcbiAgICAgIGFwcC5oYW5kbGVTdXNwZW5kKClcbiAgICAgIGNvbnRpbnVlXG4gICAgfVxuXG4gICAgYXBwLmhhbmRsZUlucHV0KHNlcXVlbmNlKVxuICAgIGNvbnN0IGV2ZW50ID0gbmV3IElucHV0RXZlbnQoaXRlbSlcbiAgICBhcHAuaW50ZXJuYWxfZXZlbnRFbWl0dGVyLmVtaXQoJ2lucHV0JywgZXZlbnQpXG5cbiAgICAvLyBBbHNvIGRpc3BhdGNoIHRocm91Z2ggdGhlIERPTSB0cmVlIHNvIG9uS2V5RG93biBoYW5kbGVycyBmaXJlLlxuICAgIGFwcC5wcm9wcy5kaXNwYXRjaEtleWJvYXJkRXZlbnQoaXRlbSlcbiAgfVxufVxuXG4vKiogRXhwb3J0ZWQgZm9yIHRlc3RpbmcuIE11dGF0ZXMgYXBwLnByb3BzLnNlbGVjdGlvbiBhbmQgY2xpY2svaG92ZXIgc3RhdGUuICovXG5leHBvcnQgZnVuY3Rpb24gaGFuZGxlTW91c2VFdmVudChhcHA6IEFwcCwgbTogUGFyc2VkTW91c2UpOiB2b2lkIHtcbiAgLy8gQWxsb3cgZGlzYWJsaW5nIGNsaWNrIGhhbmRsaW5nIHdoaWxlIGtlZXBpbmcgd2hlZWwgc2Nyb2xsICh3aGljaCBnb2VzXG4gIC8vIHRocm91Z2ggdGhlIGtleWJpbmRpbmcgc3lzdGVtIGFzICd3aGVlbHVwJy8nd2hlZWxkb3duJywgbm90IGhlcmUpLlxuICBpZiAoaXNNb3VzZUNsaWNrc0Rpc2FibGVkKCkpIHJldHVyblxuXG4gIGNvbnN0IHNlbCA9IGFwcC5wcm9wcy5zZWxlY3Rpb25cbiAgLy8gVGVybWluYWwgY29vcmRzIGFyZSAxLWluZGV4ZWQ7IHNjcmVlbiBidWZmZXIgaXMgMC1pbmRleGVkXG4gIGNvbnN0IGNvbCA9IG0uY29sIC0gMVxuICBjb25zdCByb3cgPSBtLnJvdyAtIDFcbiAgY29uc3QgYmFzZUJ1dHRvbiA9IG0uYnV0dG9uICYgMHgwM1xuXG4gIGlmIChtLmFjdGlvbiA9PT0gJ3ByZXNzJykge1xuICAgIGlmICgobS5idXR0b24gJiAweDIwKSAhPT0gMCAmJiBiYXNlQnV0dG9uID09PSAzKSB7XG4gICAgICAvLyBNb2RlLTEwMDMgbW90aW9uIHdpdGggbm8gYnV0dG9uIGhlbGQuIERpc3BhdGNoIGhvdmVyOyBza2lwIHRoZVxuICAgICAgLy8gcmVzdCBvZiB0aGlzIGhhbmRsZXIgKG5vIHNlbGVjdGlvbiwgbm8gY2xpY2stY291bnQgc2lkZSBlZmZlY3RzKS5cbiAgICAgIC8vIExvc3QtcmVsZWFzZSByZWNvdmVyeTogbm8tYnV0dG9uIG1vdGlvbiB3aGlsZSBpc0RyYWdnaW5nPXRydWUgbWVhbnNcbiAgICAgIC8vIHRoZSByZWxlYXNlIGhhcHBlbmVkIG91dHNpZGUgdGhlIHRlcm1pbmFsIHdpbmRvdyAoaVRlcm0yIGRvZXNuJ3RcbiAgICAgIC8vIGNhcHR1cmUgdGhlIHBvaW50ZXIgcGFzdCB3aW5kb3cgYm91bmRzLCBzbyB0aGUgU0dSICdtJyBuZXZlclxuICAgICAgLy8gYXJyaXZlcykuIEZpbmlzaCB0aGUgc2VsZWN0aW9uIGhlcmUgc28gY29weS1vbi1zZWxlY3QgZmlyZXMuIFRoZVxuICAgICAgLy8gRk9DVVNfT1VUIGhhbmRsZXIgY292ZXJzIHRoZSBcInN3aXRjaGVkIGFwcHNcIiBjYXNlIGJ1dCBub3QgXCJyZWxlYXNlZFxuICAgICAgLy8gcGFzdCB0aGUgZWRnZSwgY2FtZSBiYWNrXCIg4oCUIGFuZCB0bXV4IGRyb3BzIGZvY3VzIGV2ZW50cyB1bmxlc3NcbiAgICAgIC8vIGBmb2N1cy1ldmVudHMgb25gIGlzIHNldCwgc28gdGhpcyBpcyB0aGUgbW9yZSByZWxpYWJsZSBzaWduYWwuXG4gICAgICBpZiAoc2VsLmlzRHJhZ2dpbmcpIHtcbiAgICAgICAgZmluaXNoU2VsZWN0aW9uKHNlbClcbiAgICAgICAgYXBwLnByb3BzLm9uU2VsZWN0aW9uQ2hhbmdlKClcbiAgICAgIH1cbiAgICAgIGlmIChjb2wgPT09IGFwcC5sYXN0SG92ZXJDb2wgJiYgcm93ID09PSBhcHAubGFzdEhvdmVyUm93KSByZXR1cm5cbiAgICAgIGFwcC5sYXN0SG92ZXJDb2wgPSBjb2xcbiAgICAgIGFwcC5sYXN0SG92ZXJSb3cgPSByb3dcbiAgICAgIGFwcC5wcm9wcy5vbkhvdmVyQXQoY29sLCByb3cpXG4gICAgICByZXR1cm5cbiAgICB9XG4gICAgaWYgKGJhc2VCdXR0b24gIT09IDApIHtcbiAgICAgIC8vIE5vbi1sZWZ0IHByZXNzIGJyZWFrcyB0aGUgbXVsdGktY2xpY2sgY2hhaW4uXG4gICAgICBhcHAuY2xpY2tDb3VudCA9IDBcbiAgICAgIHJldHVyblxuICAgIH1cbiAgICBpZiAoKG0uYnV0dG9uICYgMHgyMCkgIT09IDApIHtcbiAgICAgIC8vIERyYWcgbW90aW9uOiBtb2RlLWF3YXJlIGV4dGVuc2lvbiAoY2hhci93b3JkL2xpbmUpLiBvblNlbGVjdGlvbkRyYWdcbiAgICAgIC8vIGNhbGxzIG5vdGlmeVNlbGVjdGlvbkNoYW5nZSBpbnRlcm5hbGx5IOKAlCBubyBleHRyYSBvblNlbGVjdGlvbkNoYW5nZS5cbiAgICAgIGFwcC5wcm9wcy5vblNlbGVjdGlvbkRyYWcoY29sLCByb3cpXG4gICAgICByZXR1cm5cbiAgICB9XG4gICAgLy8gTG9zdC1yZWxlYXNlIGZhbGxiYWNrIGZvciBtb2RlLTEwMDItb25seSB0ZXJtaW5hbHM6IGEgZnJlc2ggcHJlc3NcbiAgICAvLyB3aGlsZSBpc0RyYWdnaW5nPXRydWUgbWVhbnMgdGhlIHByZXZpb3VzIHJlbGVhc2Ugd2FzIGRyb3BwZWQgKGN1cnNvclxuICAgIC8vIGxlZnQgdGhlIHdpbmRvdykuIEZpbmlzaCB0aGF0IHNlbGVjdGlvbiBzbyBjb3B5LW9uLXNlbGVjdCBmaXJlc1xuICAgIC8vIGJlZm9yZSBzdGFydFNlbGVjdGlvbi9vbk11bHRpQ2xpY2sgY2xvYmJlcnMgaXQuIE1vZGUtMTAwMyB0ZXJtaW5hbHNcbiAgICAvLyBoaXQgdGhlIG5vLWJ1dHRvbi1tb3Rpb24gcmVjb3ZlcnkgYWJvdmUgaW5zdGVhZCwgc28gdGhpcyBpcyByYXJlLlxuICAgIGlmIChzZWwuaXNEcmFnZ2luZykge1xuICAgICAgZmluaXNoU2VsZWN0aW9uKHNlbClcbiAgICAgIGFwcC5wcm9wcy5vblNlbGVjdGlvbkNoYW5nZSgpXG4gICAgfVxuICAgIC8vIEZyZXNoIGxlZnQgcHJlc3MuIERldGVjdCBtdWx0aS1jbGljayBIRVJFIChub3Qgb24gcmVsZWFzZSkgc28gdGhlXG4gICAgLy8gd29yZC9saW5lIGhpZ2hsaWdodCBhcHBlYXJzIGltbWVkaWF0ZWx5IGFuZCBhIHN1YnNlcXVlbnQgZHJhZyBjYW5cbiAgICAvLyBleHRlbmQgYnkgd29yZC9saW5lIGxpa2UgbmF0aXZlIG1hY09TLiBQcmV2aW91c2x5IGRldGVjdGVkIG9uXG4gICAgLy8gcmVsZWFzZSwgd2hpY2ggbWVhbnQgKGEpIHZpc2libGUgbGF0ZW5jeSBiZWZvcmUgdGhlIHdvcmQgaGlnaGxpZ2h0c1xuICAgIC8vIGFuZCAoYikgZG91YmxlLWNsaWNrK2RyYWcgZmVsbCB0aHJvdWdoIHRvIGNoYXItbW9kZSBzZWxlY3Rpb24uXG4gICAgY29uc3Qgbm93ID0gRGF0ZS5ub3coKVxuICAgIGNvbnN0IG5lYXJMYXN0ID1cbiAgICAgIG5vdyAtIGFwcC5sYXN0Q2xpY2tUaW1lIDwgTVVMVElfQ0xJQ0tfVElNRU9VVF9NUyAmJlxuICAgICAgTWF0aC5hYnMoY29sIC0gYXBwLmxhc3RDbGlja0NvbCkgPD0gTVVMVElfQ0xJQ0tfRElTVEFOQ0UgJiZcbiAgICAgIE1hdGguYWJzKHJvdyAtIGFwcC5sYXN0Q2xpY2tSb3cpIDw9IE1VTFRJX0NMSUNLX0RJU1RBTkNFXG4gICAgYXBwLmNsaWNrQ291bnQgPSBuZWFyTGFzdCA/IGFwcC5jbGlja0NvdW50ICsgMSA6IDFcbiAgICBhcHAubGFzdENsaWNrVGltZSA9IG5vd1xuICAgIGFwcC5sYXN0Q2xpY2tDb2wgPSBjb2xcbiAgICBhcHAubGFzdENsaWNrUm93ID0gcm93XG4gICAgaWYgKGFwcC5jbGlja0NvdW50ID49IDIpIHtcbiAgICAgIC8vIENhbmNlbCBhbnkgcGVuZGluZyBoeXBlcmxpbmstb3BlbiBmcm9tIHRoZSBmaXJzdCBjbGljayDigJQgdGhpcyBpc1xuICAgICAgLy8gYSBkb3VibGUtY2xpY2ssIG5vdCBhIHNpbmdsZS1jbGljayBvbiBhIGxpbmsuXG4gICAgICBpZiAoYXBwLnBlbmRpbmdIeXBlcmxpbmtUaW1lcikge1xuICAgICAgICBjbGVhclRpbWVvdXQoYXBwLnBlbmRpbmdIeXBlcmxpbmtUaW1lcilcbiAgICAgICAgYXBwLnBlbmRpbmdIeXBlcmxpbmtUaW1lciA9IG51bGxcbiAgICAgIH1cbiAgICAgIC8vIENhcCBhdCAzIChsaW5lIHNlbGVjdCkgZm9yIHF1YWRydXBsZSsgY2xpY2tzLlxuICAgICAgY29uc3QgY291bnQgPSBhcHAuY2xpY2tDb3VudCA9PT0gMiA/IDIgOiAzXG4gICAgICBhcHAucHJvcHMub25NdWx0aUNsaWNrKGNvbCwgcm93LCBjb3VudClcbiAgICAgIHJldHVyblxuICAgIH1cbiAgICBzdGFydFNlbGVjdGlvbihzZWwsIGNvbCwgcm93KVxuICAgIC8vIFNHUiBiaXQgMHgwOCA9IGFsdCAoeHRlcm0uanMgd2lyZXMgYWx0S2V5IGhlcmUsIG5vdCBtZXRhS2V5IOKAlCBzZWVcbiAgICAvLyBjb21tZW50IGF0IHRoZSBoeXBlcmxpbmstb3BlbiBndWFyZCBiZWxvdykuIE9uIG1hY09TIHh0ZXJtLmpzLFxuICAgIC8vIHJlY2VpdmluZyBhbHQgbWVhbnMgbWFjT3B0aW9uQ2xpY2tGb3JjZXNTZWxlY3Rpb24gaXMgT0ZGIChvdGhlcndpc2VcbiAgICAvLyB4dGVybS5qcyB3b3VsZCBoYXZlIGNvbnN1bWVkIHRoZSBldmVudCBmb3IgbmF0aXZlIHNlbGVjdGlvbikuXG4gICAgc2VsLmxhc3RQcmVzc0hhZEFsdCA9IChtLmJ1dHRvbiAmIDB4MDgpICE9PSAwXG4gICAgYXBwLnByb3BzLm9uU2VsZWN0aW9uQ2hhbmdlKClcbiAgICByZXR1cm5cbiAgfVxuXG4gIC8vIFJlbGVhc2U6IGVuZCB0aGUgZHJhZyBldmVuIGZvciBub24temVybyBidXR0b24gY29kZXMuIFNvbWUgdGVybWluYWxzXG4gIC8vIGVuY29kZSByZWxlYXNlIHdpdGggdGhlIG1vdGlvbiBiaXQgb3IgYnV0dG9uPTMgXCJubyBidXR0b25cIiAoY2FycmllZFxuICAvLyBvdmVyIGZyb20gcHJlLVNHUiBYMTAgZW5jb2RpbmcpIOKAlCBmaWx0ZXJpbmcgdGhvc2Ugd291bGQgb3JwaGFuXG4gIC8vIGlzRHJhZ2dpbmc9dHJ1ZSBhbmQgbGVhdmUgZHJhZy10by1zY3JvbGwncyB0aW1lciBydW5uaW5nIHVudGlsIHRoZVxuICAvLyBzY3JvbGwgYm91bmRhcnkuIE9ubHkgYWN0IG9uIG5vbi1sZWZ0IHJlbGVhc2VzIHdoZW4gd2UgQVJFIGRyYWdnaW5nXG4gIC8vIChzbyBhbiB1bnJlbGF0ZWQgbWlkZGxlL3JpZ2h0IGNsaWNrLXJlbGVhc2UgZG9lc24ndCB0b3VjaCBzZWxlY3Rpb24pLlxuICBpZiAoYmFzZUJ1dHRvbiAhPT0gMCkge1xuICAgIGlmICghc2VsLmlzRHJhZ2dpbmcpIHJldHVyblxuICAgIGZpbmlzaFNlbGVjdGlvbihzZWwpXG4gICAgYXBwLnByb3BzLm9uU2VsZWN0aW9uQ2hhbmdlKClcbiAgICByZXR1cm5cbiAgfVxuICBmaW5pc2hTZWxlY3Rpb24oc2VsKVxuICAvLyBOT1RFOiB1bmxpa2UgdGhlIG9sZCByZWxlYXNlLWJhc2VkIGRldGVjdGlvbiB3ZSBkbyBOT1QgcmVzZXQgY2xpY2tDb3VudFxuICAvLyBvbiByZWxlYXNlLWFmdGVyLWRyYWcuIFRoaXMgYWxpZ25zIHdpdGggTlNFdmVudC5jbGlja0NvdW50IHNlbWFudGljczpcbiAgLy8gYW4gaW50ZXJ2ZW5pbmcgZHJhZyBkb2Vzbid0IGJyZWFrIHRoZSBjbGljayBjaGFpbi4gUHJhY3RpY2FsIHVwc2lkZTpcbiAgLy8gdHJhY2twYWQgaml0dGVyIGR1cmluZyBhbiBpbnRlbmRlZCBkb3VibGUtY2xpY2sgKHByZXNz4oaSd29iYmxl4oaScmVsZWFzZVxuICAvLyDihpJwcmVzcykgbm93IGNvcnJlY3RseSByZXNvbHZlcyB0byB3b3JkLXNlbGVjdCBpbnN0ZWFkIG9mIGJyZWFraW5nIHRvIGFcbiAgLy8gZnJlc2ggc2luZ2xlIGNsaWNrLiBUaGUgbmVhckxhc3Qgd2luZG93ICg1MDBtcywgMSBjZWxsKSBib3VuZHMgdGhlXG4gIC8vIGVmZmVjdCDigJQgYSBkZWxpYmVyYXRlIGRyYWcgcGFzdCB0aGF0IGp1c3Qgc3RhcnRzIGEgZnJlc2ggY2hhaW4uXG4gIC8vIEEgcHJlc3MrcmVsZWFzZSB3aXRoIG5vIGRyYWcgaW4gY2hhciBtb2RlIGlzIGEgY2xpY2s6IGFuY2hvciBzZXQsXG4gIC8vIGZvY3VzIG51bGwg4oaSIGhhc1NlbGVjdGlvbiBmYWxzZS4gSW4gd29yZC9saW5lIG1vZGUgdGhlIHByZXNzIGFscmVhZHlcbiAgLy8gc2V0IGFuY2hvcitmb2N1cyAoaGFzU2VsZWN0aW9uIHRydWUpLCBzbyByZWxlYXNlIGp1c3Qga2VlcHMgdGhlXG4gIC8vIGhpZ2hsaWdodC4gVGhlIGFuY2hvciBjaGVjayBndWFyZHMgYWdhaW5zdCBhbiBvcnBoYW5lZCByZWxlYXNlIChub1xuICAvLyBwcmlvciBwcmVzcyDigJQgZS5nLiBidXR0b24gd2FzIGhlbGQgd2hlbiBtb3VzZSB0cmFja2luZyB3YXMgZW5hYmxlZCkuXG4gIGlmICghaGFzU2VsZWN0aW9uKHNlbCkgJiYgc2VsLmFuY2hvcikge1xuICAgIC8vIFNpbmdsZSBjbGljazogZGlzcGF0Y2ggRE9NIGNsaWNrIGltbWVkaWF0ZWx5IChjdXJzb3IgcmVwb3NpdGlvbmluZ1xuICAgIC8vIGV0Yy4gYXJlIGxhdGVuY3ktc2Vuc2l0aXZlKS4gSWYgbm8gRE9NIGhhbmRsZXIgY29uc3VtZWQgaXQsIGRlZmVyXG4gICAgLy8gdGhlIGh5cGVybGluayBjaGVjayBzbyBhIHNlY29uZCBjbGljayBjYW4gY2FuY2VsIGl0LlxuICAgIGlmICghYXBwLnByb3BzLm9uQ2xpY2tBdChjb2wsIHJvdykpIHtcbiAgICAgIC8vIFJlc29sdmUgdGhlIGh5cGVybGluayBVUkwgc3luY2hyb25vdXNseSB3aGlsZSB0aGUgc2NyZWVuIGJ1ZmZlclxuICAgICAgLy8gc3RpbGwgcmVmbGVjdHMgd2hhdCB0aGUgdXNlciBjbGlja2VkIOKAlCBkZWZlcnJpbmcgb25seSB0aGVcbiAgICAgIC8vIGJyb3dzZXItb3BlbiBzbyBkb3VibGUtY2xpY2sgY2FuIGNhbmNlbCBpdC5cbiAgICAgIGNvbnN0IHVybCA9IGFwcC5wcm9wcy5nZXRIeXBlcmxpbmtBdChjb2wsIHJvdylcbiAgICAgIC8vIHh0ZXJtLmpzIChWUyBDb2RlLCBDdXJzb3IsIFdpbmRzdXJmLCBldGMuKSBoYXMgaXRzIG93biBPU0MgOCBsaW5rXG4gICAgICAvLyBoYW5kbGVyIHRoYXQgZmlyZXMgb24gQ21kK2NsaWNrICp3aXRob3V0IGNvbnN1bWluZyB0aGUgbW91c2UgZXZlbnQqXG4gICAgICAvLyAoTGlua2lmaWVyLl9oYW5kbGVNb3VzZVVwIGNhbGxzIGxpbmsuYWN0aXZhdGUoKSBidXQgbmV2ZXJcbiAgICAgIC8vIHByZXZlbnREZWZhdWx0L3N0b3BQcm9wYWdhdGlvbikuIFRoZSBjbGljayBpcyBhbHNvIGZvcndhcmRlZCB0byB0aGVcbiAgICAgIC8vIHB0eSBhcyBTR1IsIHNvIGJvdGggVlMgQ29kZSdzIHRlcm1pbmFsTGlua01hbmFnZXIgQU5EIG91ciBoYW5kbGVyXG4gICAgICAvLyBoZXJlIHdvdWxkIG9wZW4gdGhlIFVSTCDigJQgdHdpY2UuIFdlIGNhbid0IGZpbHRlciBvbiBDbWQ6IHh0ZXJtLmpzXG4gICAgICAvLyBkcm9wcyBtZXRhS2V5IGJlZm9yZSBTR1IgZW5jb2RpbmcgKElDb3JlTW91c2VFdmVudCBoYXMgbm8gbWV0YVxuICAgICAgLy8gZmllbGQ7IHRoZSBTR1IgYml0IHdlIGNhbGwgJ21ldGEnIGlzIHdpcmVkIHRvIGFsdCkuIExldCB4dGVybS5qc1xuICAgICAgLy8gb3duIGxpbmstb3BlbmluZzsgQ21kK2NsaWNrIGlzIHRoZSBuYXRpdmUgVVggdGhlcmUgYW55d2F5LlxuICAgICAgLy8gVEVSTV9QUk9HUkFNIGlzIHRoZSBzeW5jIGZhc3QtcGF0aDsgaXNYdGVybUpzKCkgaXMgdGhlIFhUVkVSU0lPTlxuICAgICAgLy8gcHJvYmUgcmVzdWx0IChjYXRjaGVzIFNTSCArIG5vbi1WUyBDb2RlIGVtYmVkZGVycyBsaWtlIEh5cGVyKS5cbiAgICAgIGlmICh1cmwgJiYgcHJvY2Vzcy5lbnYuVEVSTV9QUk9HUkFNICE9PSAndnNjb2RlJyAmJiAhaXNYdGVybUpzKCkpIHtcbiAgICAgICAgLy8gQ2xlYXIgYW55IHByaW9yIHBlbmRpbmcgdGltZXIg4oCUIGNsaWNraW5nIGEgc2Vjb25kIGxpbmtcbiAgICAgICAgLy8gc3VwZXJzZWRlcyB0aGUgZmlyc3QgKG9ubHkgdGhlIGxhdGVzdCBjbGljayBvcGVucykuXG4gICAgICAgIGlmIChhcHAucGVuZGluZ0h5cGVybGlua1RpbWVyKSB7XG4gICAgICAgICAgY2xlYXJUaW1lb3V0KGFwcC5wZW5kaW5nSHlwZXJsaW5rVGltZXIpXG4gICAgICAgIH1cbiAgICAgICAgYXBwLnBlbmRpbmdIeXBlcmxpbmtUaW1lciA9IHNldFRpbWVvdXQoXG4gICAgICAgICAgKGFwcCwgdXJsKSA9PiB7XG4gICAgICAgICAgICBhcHAucGVuZGluZ0h5cGVybGlua1RpbWVyID0gbnVsbFxuICAgICAgICAgICAgYXBwLnByb3BzLm9uT3Blbkh5cGVybGluayh1cmwpXG4gICAgICAgICAgfSxcbiAgICAgICAgICBNVUxUSV9DTElDS19USU1FT1VUX01TLFxuICAgICAgICAgIGFwcCxcbiAgICAgICAgICB1cmwsXG4gICAgICAgIClcbiAgICAgIH1cbiAgICB9XG4gIH1cbiAgYXBwLnByb3BzLm9uU2VsZWN0aW9uQ2hhbmdlKClcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBT0EsS0FBSyxJQUFJQyxhQUFhLEVBQUUsS0FBS0MsU0FBUyxRQUFRLE9BQU87QUFDNUQsU0FBU0MseUJBQXlCLFFBQVEsMEJBQTBCO0FBQ3BFLFNBQVNDLGVBQWUsUUFBUSxzQkFBc0I7QUFDdEQsU0FBU0MsdUJBQXVCLFFBQVEsMkJBQTJCO0FBQ25FLFNBQVNDLFdBQVcsUUFBUSx5QkFBeUI7QUFDckQsU0FBU0MscUJBQXFCLFFBQVEsMkJBQTJCO0FBQ2pFLFNBQVNDLFFBQVEsUUFBUSxvQkFBb0I7QUFDN0MsU0FBU0MsWUFBWSxRQUFRLHNCQUFzQjtBQUNuRCxTQUFTQyxVQUFVLFFBQVEsMEJBQTBCO0FBQ3JELFNBQVNDLGtCQUFrQixRQUFRLG1DQUFtQztBQUN0RSxTQUNFQyxhQUFhLEVBQ2IsS0FBS0MsV0FBVyxFQUNoQixLQUFLQyxTQUFTLEVBQ2QsS0FBS0MsV0FBVyxFQUNoQkMsdUJBQXVCLFFBQ2xCLHNCQUFzQjtBQUM3QixPQUFPQyxVQUFVLE1BQU0sa0JBQWtCO0FBQ3pDLFNBQ0VDLGVBQWUsRUFDZkMsWUFBWSxFQUNaLEtBQUtDLGNBQWMsRUFDbkJDLGNBQWMsUUFDVCxpQkFBaUI7QUFDeEIsU0FDRUMsU0FBUyxFQUNUQyxnQkFBZ0IsRUFDaEJDLG9CQUFvQixRQUNmLGdCQUFnQjtBQUN2QixTQUNFQyxrQkFBa0IsRUFDbEJDLGtCQUFrQixRQUNiLDRCQUE0QjtBQUNuQyxTQUFTQyxlQUFlLEVBQUVDLFNBQVMsUUFBUSx3QkFBd0I7QUFDbkUsU0FDRUMsc0JBQXNCLEVBQ3RCQyx5QkFBeUIsRUFDekJDLHFCQUFxQixFQUNyQkMsd0JBQXdCLEVBQ3hCQyxRQUFRLEVBQ1JDLFNBQVMsUUFDSixrQkFBa0I7QUFDekIsU0FDRUMsR0FBRyxFQUNIQyxHQUFHLEVBQ0hDLHNCQUFzQixFQUN0QkMsR0FBRyxFQUNIQyxHQUFHLEVBQ0hDLFdBQVcsRUFDWEMsV0FBVyxRQUNOLGtCQUFrQjtBQUN6QixPQUFPQyxVQUFVLE1BQU0saUJBQWlCO0FBQ3hDLFNBQVNDLGFBQWEsUUFBUSxtQkFBbUI7QUFDakQsT0FBT0Msd0JBQXdCLElBQzdCLEtBQUtDLHVCQUF1QixRQUN2QiwrQkFBK0I7QUFDdEMsT0FBT0MsYUFBYSxNQUFNLG9CQUFvQjtBQUM5QyxPQUFPQyxZQUFZLE1BQU0sbUJBQW1CO0FBQzVDLFNBQVNDLHFCQUFxQixRQUFRLDJCQUEyQjtBQUNqRSxTQUFTQyxtQkFBbUIsUUFBUSwwQkFBMEI7O0FBRTlEO0FBQ0EsTUFBTUMsZ0JBQWdCLEdBQUdDLE9BQU8sQ0FBQ0MsUUFBUSxLQUFLLE9BQU87O0FBRXJEO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSxNQUFNQyxtQkFBbUIsR0FBRyxJQUFJO0FBRWhDLEtBQUtDLEtBQUssR0FBRztFQUNYLFNBQVNDLFFBQVEsRUFBRXJELFNBQVM7RUFDNUIsU0FBU3NELEtBQUssRUFBRUMsTUFBTSxDQUFDQyxVQUFVO0VBQ2pDLFNBQVNDLE1BQU0sRUFBRUYsTUFBTSxDQUFDRyxXQUFXO0VBQ25DLFNBQVNDLE1BQU0sRUFBRUosTUFBTSxDQUFDRyxXQUFXO0VBQ25DLFNBQVNFLFdBQVcsRUFBRSxPQUFPO0VBQzdCLFNBQVNDLE1BQU0sRUFBRSxDQUFDQyxLQUFhLENBQVAsRUFBRUMsS0FBSyxFQUFFLEdBQUcsSUFBSTtFQUN4QyxTQUFTQyxlQUFlLEVBQUUsTUFBTTtFQUNoQyxTQUFTQyxZQUFZLEVBQUUsTUFBTTtFQUM3QjtFQUNBO0VBQ0E7RUFDQTtFQUNBLFNBQVNDLFNBQVMsRUFBRWhELGNBQWM7RUFDbEMsU0FBU2lELGlCQUFpQixFQUFFLEdBQUcsR0FBRyxJQUFJO0VBQ3RDO0VBQ0E7RUFDQTtFQUNBO0VBQ0EsU0FBU0MsU0FBUyxFQUFFLENBQUNDLEdBQUcsRUFBRSxNQUFNLEVBQUVDLEdBQUcsRUFBRSxNQUFNLEVBQUUsR0FBRyxPQUFPO0VBQ3pEO0VBQ0E7RUFDQTtFQUNBLFNBQVNDLFNBQVMsRUFBRSxDQUFDRixHQUFHLEVBQUUsTUFBTSxFQUFFQyxHQUFHLEVBQUUsTUFBTSxFQUFFLEdBQUcsSUFBSTtFQUN0RDtFQUNBO0VBQ0E7RUFDQSxTQUFTRSxjQUFjLEVBQUUsQ0FBQ0gsR0FBRyxFQUFFLE1BQU0sRUFBRUMsR0FBRyxFQUFFLE1BQU0sRUFBRSxHQUFHLE1BQU0sR0FBRyxTQUFTO0VBQ3pFO0VBQ0EsU0FBU0csZUFBZSxFQUFFLENBQUNDLEdBQUcsRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJO0VBQy9DO0VBQ0E7RUFDQTtFQUNBO0VBQ0EsU0FBU0MsWUFBWSxFQUFFLENBQUNOLEdBQUcsRUFBRSxNQUFNLEVBQUVDLEdBQUcsRUFBRSxNQUFNLEVBQUVNLEtBQUssRUFBRSxDQUFDLEdBQUcsQ0FBQyxFQUFFLEdBQUcsSUFBSTtFQUN2RTtFQUNBO0VBQ0E7RUFDQSxTQUFTQyxlQUFlLEVBQUUsQ0FBQ1IsR0FBRyxFQUFFLE1BQU0sRUFBRUMsR0FBRyxFQUFFLE1BQU0sRUFBRSxHQUFHLElBQUk7RUFDNUQ7RUFDQTtFQUNBO0VBQ0E7RUFDQSxTQUFTUSxhQUFhLENBQUMsRUFBRSxHQUFHLEdBQUcsSUFBSTtFQUNuQztFQUNBO0VBQ0E7RUFDQTtFQUNBLFNBQVNDLG1CQUFtQixDQUFDLEVBQUVwQyx1QkFBdUI7RUFDdEQ7RUFDQTtFQUNBLFNBQVNxQyxxQkFBcUIsRUFBRSxDQUFDQyxTQUFTLEVBQUVyRSxTQUFTLEVBQUUsR0FBRyxJQUFJO0FBQ2hFLENBQUM7O0FBRUQ7QUFDQTtBQUNBLE1BQU1zRSxzQkFBc0IsR0FBRyxHQUFHO0FBQ2xDLE1BQU1DLG9CQUFvQixHQUFHLENBQUM7QUFFOUIsS0FBS0MsS0FBSyxHQUFHO0VBQ1gsU0FBU3RCLEtBQUssQ0FBQyxFQUFFQyxLQUFLO0FBQ3hCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0EsZUFBZSxNQUFNc0IsR0FBRyxTQUFTdEYsYUFBYSxDQUFDcUQsS0FBSyxFQUFFZ0MsS0FBSyxDQUFDLENBQUM7RUFDM0QsT0FBT0UsV0FBVyxHQUFHLGFBQWE7RUFFbEMsT0FBT0Msd0JBQXdCQSxDQUFDekIsS0FBSyxFQUFFQyxLQUFLLEVBQUU7SUFDNUMsT0FBTztNQUFFRDtJQUFNLENBQUM7RUFDbEI7RUFFQSxTQUFTMEIsS0FBSyxHQUFHO0lBQ2YxQixLQUFLLEVBQUUyQjtFQUNULENBQUM7O0VBRUQ7RUFDQTtFQUNBQyxtQkFBbUIsR0FBRyxDQUFDO0VBRXZCQyxxQkFBcUIsR0FBRyxJQUFJcEYsWUFBWSxDQUFDLENBQUM7RUFDMUNxRixhQUFhLEdBQUdsRixhQUFhO0VBQzdCO0VBQ0FtRixxQkFBcUIsRUFBRXRDLE1BQU0sQ0FBQ3VDLE9BQU8sR0FBRyxJQUFJLEdBQUcsSUFBSTtFQUNuRDtFQUNBLFNBQVNDLGNBQWMsR0FBRyxFQUFFLEVBQUM7RUFDN0IsU0FBU0MsYUFBYSxHQUFHLEdBQUcsRUFBQzs7RUFFN0I7RUFDQTtFQUNBQyxPQUFPLEdBQUcsSUFBSXhFLGVBQWUsQ0FBQyxJQUFJLENBQUN5RSxLQUFLLENBQUN6QyxNQUFNLENBQUM7O0VBRWhEO0VBQ0E7RUFDQTtFQUNBMEMsYUFBYSxHQUFHLENBQUM7RUFDakJDLFlBQVksR0FBRyxDQUFDLENBQUM7RUFDakJDLFlBQVksR0FBRyxDQUFDLENBQUM7RUFDakJDLFVBQVUsR0FBRyxDQUFDO0VBQ2Q7RUFDQTtFQUNBO0VBQ0E7RUFDQUMscUJBQXFCLEVBQUVDLFVBQVUsQ0FBQyxPQUFPQyxVQUFVLENBQUMsR0FBRyxJQUFJLEdBQUcsSUFBSTtFQUNsRTtFQUNBO0VBQ0E7RUFDQUMsWUFBWSxHQUFHLENBQUMsQ0FBQztFQUNqQkMsWUFBWSxHQUFHLENBQUMsQ0FBQzs7RUFFakI7RUFDQTtFQUNBO0VBQ0FDLGFBQWEsR0FBR0MsSUFBSSxDQUFDQyxHQUFHLENBQUMsQ0FBQzs7RUFFMUI7RUFDQUMsa0JBQWtCQSxDQUFBLENBQUUsRUFBRSxPQUFPLENBQUM7SUFDNUIsT0FBTyxJQUFJLENBQUNiLEtBQUssQ0FBQzVDLEtBQUssQ0FBQzBELEtBQUs7RUFDL0I7RUFFQSxTQUFTQyxNQUFNQSxDQUFBLEVBQUc7SUFDaEIsT0FDRSxDQUFDLG1CQUFtQixDQUFDLFFBQVEsQ0FDM0IsS0FBSyxDQUFDLENBQUM7TUFDTEMsT0FBTyxFQUFFLElBQUksQ0FBQ2hCLEtBQUssQ0FBQ2xDLGVBQWU7TUFDbkNtRCxJQUFJLEVBQUUsSUFBSSxDQUFDakIsS0FBSyxDQUFDakM7SUFDbkIsQ0FBQyxDQUFDO0FBRVYsUUFBUSxDQUFDLFVBQVUsQ0FBQyxRQUFRLENBQ2xCLEtBQUssQ0FBQyxDQUFDO1FBQ0xtRCxJQUFJLEVBQUUsSUFBSSxDQUFDQztNQUNiLENBQUMsQ0FBQztBQUVaLFVBQVUsQ0FBQyxZQUFZLENBQUMsUUFBUSxDQUNwQixLQUFLLENBQUMsQ0FBQztVQUNML0QsS0FBSyxFQUFFLElBQUksQ0FBQzRDLEtBQUssQ0FBQzVDLEtBQUs7VUFDdkJnRSxVQUFVLEVBQUUsSUFBSSxDQUFDQyxnQkFBZ0I7VUFDakNSLGtCQUFrQixFQUFFLElBQUksQ0FBQ0Esa0JBQWtCLENBQUMsQ0FBQztVQUU3Q1Msb0JBQW9CLEVBQUUsSUFBSSxDQUFDdEIsS0FBSyxDQUFDdEMsV0FBVztVQUU1QytCLHFCQUFxQixFQUFFLElBQUksQ0FBQ0EscUJBQXFCO1VBQ2pEOEIsZ0JBQWdCLEVBQUUsSUFBSSxDQUFDeEI7UUFDekIsQ0FBQyxDQUFDO0FBRWQsWUFBWSxDQUFDLHFCQUFxQjtBQUNsQyxjQUFjLENBQUMsYUFBYTtBQUM1QixnQkFBZ0IsQ0FBQyx3QkFBd0IsQ0FBQyxRQUFRLENBQ2hDLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQ0MsS0FBSyxDQUFDbkIsbUJBQW1CLEtBQUssTUFBTSxDQUFDLENBQUMsQ0FBQyxDQUFDO0FBRXRFLGtCQUFrQixDQUFDLElBQUksQ0FBQ1MsS0FBSyxDQUFDMUIsS0FBSyxHQUNmLENBQUMsYUFBYSxDQUFDLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQzBCLEtBQUssQ0FBQzFCLEtBQUssSUFBSUMsS0FBSyxDQUFDLEdBQUcsR0FFbkQsSUFBSSxDQUFDbUMsS0FBSyxDQUFDN0MsUUFDWjtBQUNuQixnQkFBZ0IsRUFBRSx3QkFBd0IsQ0FBQyxRQUFRO0FBQ25ELGNBQWMsRUFBRSxhQUFhO0FBQzdCLFlBQVksRUFBRSxxQkFBcUI7QUFDbkMsVUFBVSxFQUFFLFlBQVksQ0FBQyxRQUFRO0FBQ2pDLFFBQVEsRUFBRSxVQUFVLENBQUMsUUFBUTtBQUM3QixNQUFNLEVBQUUsbUJBQW1CLENBQUMsUUFBUSxDQUFDO0VBRW5DO0VBRUEsU0FBU3FFLGlCQUFpQkEsQ0FBQSxFQUFHO0lBQzNCO0lBQ0EsSUFDRSxJQUFJLENBQUN4QixLQUFLLENBQUN6QyxNQUFNLENBQUN1RCxLQUFLLElBQ3ZCLENBQUM1RyxXQUFXLENBQUM2QyxPQUFPLENBQUMwRSxHQUFHLENBQUNDLHlCQUF5QixDQUFDLEVBQ25EO01BQ0EsSUFBSSxDQUFDMUIsS0FBSyxDQUFDekMsTUFBTSxDQUFDb0UsS0FBSyxDQUFDdkYsV0FBVyxDQUFDO0lBQ3RDO0VBQ0Y7RUFFQSxTQUFTd0Ysb0JBQW9CQSxDQUFBLEVBQUc7SUFDOUIsSUFBSSxJQUFJLENBQUM1QixLQUFLLENBQUN6QyxNQUFNLENBQUN1RCxLQUFLLEVBQUU7TUFDM0IsSUFBSSxDQUFDZCxLQUFLLENBQUN6QyxNQUFNLENBQUNvRSxLQUFLLENBQUN0RixXQUFXLENBQUM7SUFDdEM7O0lBRUE7SUFDQSxJQUFJLElBQUksQ0FBQ3NELHFCQUFxQixFQUFFO01BQzlCa0MsWUFBWSxDQUFDLElBQUksQ0FBQ2xDLHFCQUFxQixDQUFDO01BQ3hDLElBQUksQ0FBQ0EscUJBQXFCLEdBQUcsSUFBSTtJQUNuQztJQUNBLElBQUksSUFBSSxDQUFDVSxxQkFBcUIsRUFBRTtNQUM5QndCLFlBQVksQ0FBQyxJQUFJLENBQUN4QixxQkFBcUIsQ0FBQztNQUN4QyxJQUFJLENBQUNBLHFCQUFxQixHQUFHLElBQUk7SUFDbkM7SUFDQTtJQUNBLElBQUksSUFBSSxDQUFDUSxrQkFBa0IsQ0FBQyxDQUFDLEVBQUU7TUFDN0IsSUFBSSxDQUFDUSxnQkFBZ0IsQ0FBQyxLQUFLLENBQUM7SUFDOUI7RUFDRjtFQUVBLFNBQVNTLGlCQUFpQkEsQ0FBQ2xFLEtBQUssRUFBRUMsS0FBSyxFQUFFO0lBQ3ZDLElBQUksQ0FBQ3NELFVBQVUsQ0FBQ3ZELEtBQUssQ0FBQztFQUN4QjtFQUVBeUQsZ0JBQWdCLEdBQUdBLENBQUNVLFNBQVMsRUFBRSxPQUFPLENBQUMsRUFBRSxJQUFJLElBQUk7SUFDL0MsTUFBTTtNQUFFM0U7SUFBTSxDQUFDLEdBQUcsSUFBSSxDQUFDNEMsS0FBSztJQUU1QixJQUFJLENBQUMsSUFBSSxDQUFDYSxrQkFBa0IsQ0FBQyxDQUFDLEVBQUU7TUFDOUIsSUFBSXpELEtBQUssS0FBS0wsT0FBTyxDQUFDSyxLQUFLLEVBQUU7UUFDM0IsTUFBTSxJQUFJUyxLQUFLLENBQ2IscU1BQ0YsQ0FBQztNQUNILENBQUMsTUFBTTtRQUNMLE1BQU0sSUFBSUEsS0FBSyxDQUNiLDBKQUNGLENBQUM7TUFDSDtJQUNGO0lBRUFULEtBQUssQ0FBQzRFLFdBQVcsQ0FBQyxNQUFNLENBQUM7SUFFekIsSUFBSUQsU0FBUyxFQUFFO01BQ2I7TUFDQSxJQUFJLElBQUksQ0FBQ3ZDLG1CQUFtQixLQUFLLENBQUMsRUFBRTtRQUNsQztRQUNBO1FBQ0E7UUFDQTtRQUNBdkYsdUJBQXVCLENBQUMsQ0FBQztRQUN6Qm1ELEtBQUssQ0FBQzZFLEdBQUcsQ0FBQyxDQUFDO1FBQ1g3RSxLQUFLLENBQUNnRSxVQUFVLENBQUMsSUFBSSxDQUFDO1FBQ3RCaEUsS0FBSyxDQUFDOEUsV0FBVyxDQUFDLFVBQVUsRUFBRSxJQUFJLENBQUNDLGNBQWMsQ0FBQztRQUNsRDtRQUNBLElBQUksQ0FBQ25DLEtBQUssQ0FBQ3pDLE1BQU0sQ0FBQ29FLEtBQUssQ0FBQ3pGLEdBQUcsQ0FBQztRQUM1QjtRQUNBLElBQUksQ0FBQzhELEtBQUssQ0FBQ3pDLE1BQU0sQ0FBQ29FLEtBQUssQ0FBQ3hGLEdBQUcsQ0FBQztRQUM1QjtRQUNBO1FBQ0E7UUFDQTtRQUNBO1FBQ0EsSUFBSWYsb0JBQW9CLENBQUMsQ0FBQyxFQUFFO1VBQzFCLElBQUksQ0FBQzRFLEtBQUssQ0FBQ3pDLE1BQU0sQ0FBQ29FLEtBQUssQ0FBQ2hHLHFCQUFxQixDQUFDO1VBQzlDLElBQUksQ0FBQ3FFLEtBQUssQ0FBQ3pDLE1BQU0sQ0FBQ29FLEtBQUssQ0FBQy9GLHdCQUF3QixDQUFDO1FBQ25EO1FBQ0E7UUFDQTtRQUNBO1FBQ0E7UUFDQTtRQUNBO1FBQ0E7UUFDQTtRQUNBd0csWUFBWSxDQUFDLE1BQU07VUFDakIsS0FBS0MsT0FBTyxDQUFDQyxHQUFHLENBQUMsQ0FDZixJQUFJLENBQUN2QyxPQUFPLENBQUN3QyxJQUFJLENBQUMvRyxTQUFTLENBQUMsQ0FBQyxDQUFDLEVBQzlCLElBQUksQ0FBQ3VFLE9BQU8sQ0FBQ3lDLEtBQUssQ0FBQyxDQUFDLENBQ3JCLENBQUMsQ0FBQ0MsSUFBSSxDQUFDLENBQUMsQ0FBQ0MsQ0FBQyxDQUFDLEtBQUs7WUFDZixJQUFJQSxDQUFDLEVBQUU7Y0FDTHZILGdCQUFnQixDQUFDdUgsQ0FBQyxDQUFDQyxJQUFJLENBQUM7Y0FDeEIzSSxlQUFlLENBQUMsc0NBQXNDMEksQ0FBQyxDQUFDQyxJQUFJLEdBQUcsQ0FBQztZQUNsRSxDQUFDLE1BQU07Y0FDTDNJLGVBQWUsQ0FBQyw4Q0FBOEMsQ0FBQztZQUNqRTtVQUNGLENBQUMsQ0FBQztRQUNKLENBQUMsQ0FBQztNQUNKO01BRUEsSUFBSSxDQUFDd0YsbUJBQW1CLEVBQUU7TUFDMUI7SUFDRjs7SUFFQTtJQUNBLElBQUksRUFBRSxJQUFJLENBQUNBLG1CQUFtQixLQUFLLENBQUMsRUFBRTtNQUNwQyxJQUFJLENBQUNRLEtBQUssQ0FBQ3pDLE1BQU0sQ0FBQ29FLEtBQUssQ0FBQ2pHLHlCQUF5QixDQUFDO01BQ2xELElBQUksQ0FBQ3NFLEtBQUssQ0FBQ3pDLE1BQU0sQ0FBQ29FLEtBQUssQ0FBQ2xHLHNCQUFzQixDQUFDO01BQy9DO01BQ0EsSUFBSSxDQUFDdUUsS0FBSyxDQUFDekMsTUFBTSxDQUFDb0UsS0FBSyxDQUFDM0YsR0FBRyxDQUFDO01BQzVCO01BQ0EsSUFBSSxDQUFDZ0UsS0FBSyxDQUFDekMsTUFBTSxDQUFDb0UsS0FBSyxDQUFDNUYsR0FBRyxDQUFDO01BQzVCcUIsS0FBSyxDQUFDZ0UsVUFBVSxDQUFDLEtBQUssQ0FBQztNQUN2QmhFLEtBQUssQ0FBQ3dGLGNBQWMsQ0FBQyxVQUFVLEVBQUUsSUFBSSxDQUFDVCxjQUFjLENBQUM7TUFDckQvRSxLQUFLLENBQUN5RixLQUFLLENBQUMsQ0FBQztJQUNmO0VBQ0YsQ0FBQzs7RUFFRDtFQUNBQyxlQUFlLEdBQUdBLENBQUEsQ0FBRSxFQUFFLElBQUksSUFBSTtJQUM1QjtJQUNBLElBQUksQ0FBQ25ELHFCQUFxQixHQUFHLElBQUk7O0lBRWpDO0lBQ0EsSUFBSSxDQUFDLElBQUksQ0FBQ0QsYUFBYSxDQUFDcUQsVUFBVSxFQUFFOztJQUVwQztJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsSUFBSSxJQUFJLENBQUMvQyxLQUFLLENBQUM1QyxLQUFLLENBQUM0RixjQUFjLEdBQUcsQ0FBQyxFQUFFO01BQ3ZDLElBQUksQ0FBQ3JELHFCQUFxQixHQUFHWSxVQUFVLENBQ3JDLElBQUksQ0FBQ3VDLGVBQWUsRUFDcEIsSUFBSSxDQUFDakQsY0FDUCxDQUFDO01BQ0Q7SUFDRjs7SUFFQTtJQUNBO0lBQ0EsSUFBSSxDQUFDb0QsWUFBWSxDQUFDLElBQUksQ0FBQztFQUN6QixDQUFDOztFQUVEO0VBQ0FBLFlBQVksR0FBR0EsQ0FBQ0MsS0FBSyxFQUFFLE1BQU0sR0FBR0MsTUFBTSxHQUFHLElBQUksQ0FBQyxFQUFFLElBQUksSUFBSTtJQUN0RDtJQUNBLE1BQU0sQ0FBQ0MsSUFBSSxFQUFFQyxRQUFRLENBQUMsR0FBR3pJLHVCQUF1QixDQUFDLElBQUksQ0FBQzhFLGFBQWEsRUFBRXdELEtBQUssQ0FBQztJQUMzRSxJQUFJLENBQUN4RCxhQUFhLEdBQUcyRCxRQUFROztJQUU3QjtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsSUFBSUQsSUFBSSxDQUFDRSxNQUFNLEdBQUcsQ0FBQyxFQUFFO01BQ25CekksVUFBVSxDQUFDMEksZUFBZSxDQUN4QkMsa0JBQWtCLEVBQ2xCLElBQUksRUFDSkosSUFBSSxFQUNKN0QsU0FBUyxFQUNUQSxTQUNGLENBQUM7SUFDSDs7SUFFQTtJQUNBLElBQUksSUFBSSxDQUFDRyxhQUFhLENBQUNxRCxVQUFVLEVBQUU7TUFDakM7TUFDQSxJQUFJLElBQUksQ0FBQ3BELHFCQUFxQixFQUFFO1FBQzlCa0MsWUFBWSxDQUFDLElBQUksQ0FBQ2xDLHFCQUFxQixDQUFDO01BQzFDO01BQ0EsSUFBSSxDQUFDQSxxQkFBcUIsR0FBR1ksVUFBVSxDQUNyQyxJQUFJLENBQUN1QyxlQUFlLEVBQ3BCLElBQUksQ0FBQ3BELGFBQWEsQ0FBQytELElBQUksS0FBSyxVQUFVLEdBQ2xDLElBQUksQ0FBQzNELGFBQWEsR0FDbEIsSUFBSSxDQUFDRCxjQUNYLENBQUM7SUFDSDtFQUNGLENBQUM7RUFFRHNDLGNBQWMsR0FBR0EsQ0FBQSxDQUFFLEVBQUUsSUFBSSxJQUFJO0lBQzNCO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsTUFBTXZCLEdBQUcsR0FBR0QsSUFBSSxDQUFDQyxHQUFHLENBQUMsQ0FBQztJQUN0QixJQUFJQSxHQUFHLEdBQUcsSUFBSSxDQUFDRixhQUFhLEdBQUd6RCxtQkFBbUIsRUFBRTtNQUNsRCxJQUFJLENBQUMrQyxLQUFLLENBQUNwQixhQUFhLEdBQUcsQ0FBQztJQUM5QjtJQUNBLElBQUksQ0FBQzhCLGFBQWEsR0FBR0UsR0FBRztJQUN4QixJQUFJO01BQ0YsSUFBSThDLEtBQUs7TUFDVCxPQUFPLENBQUNBLEtBQUssR0FBRyxJQUFJLENBQUMxRCxLQUFLLENBQUM1QyxLQUFLLENBQUN1RyxJQUFJLENBQUMsQ0FBQyxJQUFJLE1BQU0sR0FBRyxJQUFJLE1BQU0sSUFBSSxFQUFFO1FBQ2xFO1FBQ0EsSUFBSSxDQUFDVixZQUFZLENBQUNTLEtBQUssQ0FBQztNQUMxQjtJQUNGLENBQUMsQ0FBQyxPQUFPOUYsS0FBSyxFQUFFO01BQ2Q7TUFDQTtNQUNBO01BQ0E7TUFDQXhELFFBQVEsQ0FBQ3dELEtBQUssQ0FBQzs7TUFFZjtNQUNBO01BQ0E7TUFDQSxNQUFNO1FBQUVSO01BQU0sQ0FBQyxHQUFHLElBQUksQ0FBQzRDLEtBQUs7TUFDNUIsSUFDRSxJQUFJLENBQUNSLG1CQUFtQixHQUFHLENBQUMsSUFDNUIsQ0FBQ3BDLEtBQUssQ0FBQ3dHLFNBQVMsQ0FBQyxVQUFVLENBQUMsQ0FBQ0MsUUFBUSxDQUFDLElBQUksQ0FBQzFCLGNBQWMsQ0FBQyxFQUMxRDtRQUNBbkksZUFBZSxDQUNiLDJFQUEyRSxFQUMzRTtVQUFFOEosS0FBSyxFQUFFO1FBQU8sQ0FDbEIsQ0FBQztRQUNEMUcsS0FBSyxDQUFDOEUsV0FBVyxDQUFDLFVBQVUsRUFBRSxJQUFJLENBQUNDLGNBQWMsQ0FBQztNQUNwRDtJQUNGO0VBQ0YsQ0FBQztFQUVENEIsV0FBVyxHQUFHQSxDQUFDYixLQUFLLEVBQUUsTUFBTSxHQUFHLFNBQVMsQ0FBQyxFQUFFLElBQUksSUFBSTtJQUNqRDtJQUNBLElBQUlBLEtBQUssS0FBSyxNQUFNLElBQUksSUFBSSxDQUFDbEQsS0FBSyxDQUFDdEMsV0FBVyxFQUFFO01BQzlDLElBQUksQ0FBQ3lELFVBQVUsQ0FBQyxDQUFDO0lBQ25COztJQUVBO0lBQ0E7SUFDQTtFQUNGLENBQUM7RUFFREEsVUFBVSxHQUFHQSxDQUFDdkQsS0FBYSxDQUFQLEVBQUVDLEtBQUssQ0FBQyxFQUFFLElBQUksSUFBSTtJQUNwQyxJQUFJLElBQUksQ0FBQ2dELGtCQUFrQixDQUFDLENBQUMsRUFBRTtNQUM3QixJQUFJLENBQUNRLGdCQUFnQixDQUFDLEtBQUssQ0FBQztJQUM5QjtJQUVBLElBQUksQ0FBQ3JCLEtBQUssQ0FBQ3JDLE1BQU0sQ0FBQ0MsS0FBSyxDQUFDO0VBQzFCLENBQUM7RUFFRG9HLG1CQUFtQixHQUFHQSxDQUFDQyxTQUFTLEVBQUUsT0FBTyxDQUFDLEVBQUUsSUFBSSxJQUFJO0lBQ2xEO0lBQ0E7SUFDQTNJLGtCQUFrQixDQUFDMkksU0FBUyxDQUFDO0VBQy9CLENBQUM7RUFFREMsYUFBYSxHQUFHQSxDQUFBLENBQUUsRUFBRSxJQUFJLElBQUk7SUFDMUIsSUFBSSxDQUFDLElBQUksQ0FBQ3JELGtCQUFrQixDQUFDLENBQUMsRUFBRTtNQUM5QjtJQUNGOztJQUVBO0lBQ0EsTUFBTXNELHlCQUF5QixHQUFHLElBQUksQ0FBQzNFLG1CQUFtQjs7SUFFMUQ7SUFDQSxPQUFPLElBQUksQ0FBQ0EsbUJBQW1CLEdBQUcsQ0FBQyxFQUFFO01BQ25DLElBQUksQ0FBQzZCLGdCQUFnQixDQUFDLEtBQUssQ0FBQztJQUM5Qjs7SUFFQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsSUFBSSxJQUFJLENBQUNyQixLQUFLLENBQUN6QyxNQUFNLENBQUN1RCxLQUFLLEVBQUU7TUFDM0IsSUFBSSxDQUFDZCxLQUFLLENBQUN6QyxNQUFNLENBQUNvRSxLQUFLLENBQUN0RixXQUFXLEdBQUdMLEdBQUcsR0FBR0Msc0JBQXNCLENBQUM7SUFDckU7O0lBRUE7SUFDQSxJQUFJLENBQUN3RCxxQkFBcUIsQ0FBQzJFLElBQUksQ0FBQyxTQUFTLENBQUM7O0lBRTFDO0lBQ0EsTUFBTUMsYUFBYSxHQUFHQSxDQUFBLEtBQU07TUFDMUI7TUFDQSxLQUFLLElBQUlDLENBQUMsR0FBRyxDQUFDLEVBQUVBLENBQUMsR0FBR0gseUJBQXlCLEVBQUVHLENBQUMsRUFBRSxFQUFFO1FBQ2xELElBQUksSUFBSSxDQUFDekQsa0JBQWtCLENBQUMsQ0FBQyxFQUFFO1VBQzdCLElBQUksQ0FBQ1EsZ0JBQWdCLENBQUMsSUFBSSxDQUFDO1FBQzdCO01BQ0Y7O01BRUE7TUFDQSxJQUFJLElBQUksQ0FBQ3JCLEtBQUssQ0FBQ3pDLE1BQU0sQ0FBQ3VELEtBQUssRUFBRTtRQUMzQixJQUFJLENBQUM1RyxXQUFXLENBQUM2QyxPQUFPLENBQUMwRSxHQUFHLENBQUNDLHlCQUF5QixDQUFDLEVBQUU7VUFDdkQsSUFBSSxDQUFDMUIsS0FBSyxDQUFDekMsTUFBTSxDQUFDb0UsS0FBSyxDQUFDdkYsV0FBVyxDQUFDO1FBQ3RDO1FBQ0E7UUFDQSxJQUFJLENBQUM0RCxLQUFLLENBQUN6QyxNQUFNLENBQUNvRSxLQUFLLENBQUN4RixHQUFHLENBQUM7TUFDOUI7O01BRUE7TUFDQSxJQUFJLENBQUNzRCxxQkFBcUIsQ0FBQzJFLElBQUksQ0FBQyxRQUFRLENBQUM7TUFFekNySCxPQUFPLENBQUM2RixjQUFjLENBQUMsU0FBUyxFQUFFeUIsYUFBYSxDQUFDO0lBQ2xELENBQUM7SUFFRHRILE9BQU8sQ0FBQ3dILEVBQUUsQ0FBQyxTQUFTLEVBQUVGLGFBQWEsQ0FBQztJQUNwQ3RILE9BQU8sQ0FBQ3lILElBQUksQ0FBQ3pILE9BQU8sQ0FBQzBILEdBQUcsRUFBRSxTQUFTLENBQUM7RUFDdEMsQ0FBQztBQUNIOztBQUVBO0FBQ0E7QUFDQSxTQUFTakIsa0JBQWtCQSxDQUN6QmtCLEdBQUcsRUFBRXZGLEdBQUcsRUFDUndGLEtBQUssRUFBRWxLLFdBQVcsRUFBRSxFQUNwQm1LLFFBQVEsRUFBRSxTQUFTLEVBQ25CQyxRQUFRLEVBQUUsU0FBUyxDQUNwQixFQUFFLElBQUksQ0FBQztFQUNOO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBLElBQ0VGLEtBQUssQ0FBQ0csSUFBSSxDQUNSUixDQUFDLElBQ0NBLENBQUMsQ0FBQ1MsSUFBSSxLQUFLLEtBQUssSUFDZlQsQ0FBQyxDQUFDUyxJQUFJLEtBQUssT0FBTyxJQUNqQixFQUFFLENBQUNULENBQUMsQ0FBQ1UsTUFBTSxHQUFHLElBQUksTUFBTSxDQUFDLElBQUksQ0FBQ1YsQ0FBQyxDQUFDVSxNQUFNLEdBQUcsSUFBSSxNQUFNLENBQUMsQ0FDMUQsQ0FBQyxFQUNEO0lBQ0FqTCx5QkFBeUIsQ0FBQyxDQUFDO0VBQzdCO0VBRUEsS0FBSyxNQUFNa0wsSUFBSSxJQUFJTixLQUFLLEVBQUU7SUFDeEI7SUFDQTtJQUNBLElBQUlNLElBQUksQ0FBQ0YsSUFBSSxLQUFLLFVBQVUsRUFBRTtNQUM1QkwsR0FBRyxDQUFDM0UsT0FBTyxDQUFDbUYsVUFBVSxDQUFDRCxJQUFJLENBQUNFLFFBQVEsQ0FBQztNQUNyQztJQUNGOztJQUVBO0lBQ0E7SUFDQTtJQUNBLElBQUlGLElBQUksQ0FBQ0YsSUFBSSxLQUFLLE9BQU8sRUFBRTtNQUN6QkssZ0JBQWdCLENBQUNWLEdBQUcsRUFBRU8sSUFBSSxDQUFDO01BQzNCO0lBQ0Y7SUFFQSxNQUFNSSxRQUFRLEdBQUdKLElBQUksQ0FBQ0ksUUFBUTs7SUFFOUI7SUFDQSxJQUFJQSxRQUFRLEtBQUt4SixRQUFRLEVBQUU7TUFDekI2SSxHQUFHLENBQUNWLG1CQUFtQixDQUFDLElBQUksQ0FBQztNQUM3QixNQUFNc0IsS0FBSyxHQUFHLElBQUkvSyxrQkFBa0IsQ0FBQyxlQUFlLENBQUM7TUFDckRtSyxHQUFHLENBQUNqRixxQkFBcUIsQ0FBQzJFLElBQUksQ0FBQyxlQUFlLEVBQUVrQixLQUFLLENBQUM7TUFDdEQ7SUFDRjtJQUNBLElBQUlELFFBQVEsS0FBS3ZKLFNBQVMsRUFBRTtNQUMxQjRJLEdBQUcsQ0FBQ1YsbUJBQW1CLENBQUMsS0FBSyxDQUFDO01BQzlCO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQSxJQUFJVSxHQUFHLENBQUMxRSxLQUFLLENBQUNoQyxTQUFTLENBQUN1SCxVQUFVLEVBQUU7UUFDbEN6SyxlQUFlLENBQUM0SixHQUFHLENBQUMxRSxLQUFLLENBQUNoQyxTQUFTLENBQUM7UUFDcEMwRyxHQUFHLENBQUMxRSxLQUFLLENBQUMvQixpQkFBaUIsQ0FBQyxDQUFDO01BQy9CO01BQ0EsTUFBTXFILEtBQUssR0FBRyxJQUFJL0ssa0JBQWtCLENBQUMsY0FBYyxDQUFDO01BQ3BEbUssR0FBRyxDQUFDakYscUJBQXFCLENBQUMyRSxJQUFJLENBQUMsY0FBYyxFQUFFa0IsS0FBSyxDQUFDO01BQ3JEO0lBQ0Y7O0lBRUE7SUFDQSxJQUFJLENBQUNqSyxrQkFBa0IsQ0FBQyxDQUFDLEVBQUU7TUFDekJDLGtCQUFrQixDQUFDLElBQUksQ0FBQztJQUMxQjs7SUFFQTtJQUNBO0lBQ0EsSUFBSTJKLElBQUksQ0FBQ3RDLElBQUksS0FBSyxHQUFHLElBQUlzQyxJQUFJLENBQUNPLElBQUksSUFBSTFJLGdCQUFnQixFQUFFO01BQ3RENEgsR0FBRyxDQUFDUixhQUFhLENBQUMsQ0FBQztNQUNuQjtJQUNGO0lBRUFRLEdBQUcsQ0FBQ1gsV0FBVyxDQUFDc0IsUUFBUSxDQUFDO0lBQ3pCLE1BQU1DLEtBQUssR0FBRyxJQUFJaEwsVUFBVSxDQUFDMkssSUFBSSxDQUFDO0lBQ2xDUCxHQUFHLENBQUNqRixxQkFBcUIsQ0FBQzJFLElBQUksQ0FBQyxPQUFPLEVBQUVrQixLQUFLLENBQUM7O0lBRTlDO0lBQ0FaLEdBQUcsQ0FBQzFFLEtBQUssQ0FBQ2xCLHFCQUFxQixDQUFDbUcsSUFBSSxDQUFDO0VBQ3ZDO0FBQ0Y7O0FBRUE7QUFDQSxPQUFPLFNBQVNHLGdCQUFnQkEsQ0FBQ1YsR0FBRyxFQUFFdkYsR0FBRyxFQUFFc0csQ0FBQyxFQUFFOUssV0FBVyxDQUFDLEVBQUUsSUFBSSxDQUFDO0VBQy9EO0VBQ0E7RUFDQSxJQUFJUixxQkFBcUIsQ0FBQyxDQUFDLEVBQUU7RUFFN0IsTUFBTXVMLEdBQUcsR0FBR2hCLEdBQUcsQ0FBQzFFLEtBQUssQ0FBQ2hDLFNBQVM7RUFDL0I7RUFDQSxNQUFNRyxHQUFHLEdBQUdzSCxDQUFDLENBQUN0SCxHQUFHLEdBQUcsQ0FBQztFQUNyQixNQUFNQyxHQUFHLEdBQUdxSCxDQUFDLENBQUNySCxHQUFHLEdBQUcsQ0FBQztFQUNyQixNQUFNdUgsVUFBVSxHQUFHRixDQUFDLENBQUNULE1BQU0sR0FBRyxJQUFJO0VBRWxDLElBQUlTLENBQUMsQ0FBQ0csTUFBTSxLQUFLLE9BQU8sRUFBRTtJQUN4QixJQUFJLENBQUNILENBQUMsQ0FBQ1QsTUFBTSxHQUFHLElBQUksTUFBTSxDQUFDLElBQUlXLFVBQVUsS0FBSyxDQUFDLEVBQUU7TUFDL0M7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0EsSUFBSUQsR0FBRyxDQUFDSCxVQUFVLEVBQUU7UUFDbEJ6SyxlQUFlLENBQUM0SyxHQUFHLENBQUM7UUFDcEJoQixHQUFHLENBQUMxRSxLQUFLLENBQUMvQixpQkFBaUIsQ0FBQyxDQUFDO01BQy9CO01BQ0EsSUFBSUUsR0FBRyxLQUFLdUcsR0FBRyxDQUFDbEUsWUFBWSxJQUFJcEMsR0FBRyxLQUFLc0csR0FBRyxDQUFDakUsWUFBWSxFQUFFO01BQzFEaUUsR0FBRyxDQUFDbEUsWUFBWSxHQUFHckMsR0FBRztNQUN0QnVHLEdBQUcsQ0FBQ2pFLFlBQVksR0FBR3JDLEdBQUc7TUFDdEJzRyxHQUFHLENBQUMxRSxLQUFLLENBQUMzQixTQUFTLENBQUNGLEdBQUcsRUFBRUMsR0FBRyxDQUFDO01BQzdCO0lBQ0Y7SUFDQSxJQUFJdUgsVUFBVSxLQUFLLENBQUMsRUFBRTtNQUNwQjtNQUNBakIsR0FBRyxDQUFDdEUsVUFBVSxHQUFHLENBQUM7TUFDbEI7SUFDRjtJQUNBLElBQUksQ0FBQ3FGLENBQUMsQ0FBQ1QsTUFBTSxHQUFHLElBQUksTUFBTSxDQUFDLEVBQUU7TUFDM0I7TUFDQTtNQUNBTixHQUFHLENBQUMxRSxLQUFLLENBQUNyQixlQUFlLENBQUNSLEdBQUcsRUFBRUMsR0FBRyxDQUFDO01BQ25DO0lBQ0Y7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsSUFBSXNILEdBQUcsQ0FBQ0gsVUFBVSxFQUFFO01BQ2xCekssZUFBZSxDQUFDNEssR0FBRyxDQUFDO01BQ3BCaEIsR0FBRyxDQUFDMUUsS0FBSyxDQUFDL0IsaUJBQWlCLENBQUMsQ0FBQztJQUMvQjtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxNQUFNMkMsR0FBRyxHQUFHRCxJQUFJLENBQUNDLEdBQUcsQ0FBQyxDQUFDO0lBQ3RCLE1BQU1pRixRQUFRLEdBQ1pqRixHQUFHLEdBQUc4RCxHQUFHLENBQUN6RSxhQUFhLEdBQUdqQixzQkFBc0IsSUFDaEQ4RyxJQUFJLENBQUNDLEdBQUcsQ0FBQzVILEdBQUcsR0FBR3VHLEdBQUcsQ0FBQ3hFLFlBQVksQ0FBQyxJQUFJakIsb0JBQW9CLElBQ3hENkcsSUFBSSxDQUFDQyxHQUFHLENBQUMzSCxHQUFHLEdBQUdzRyxHQUFHLENBQUN2RSxZQUFZLENBQUMsSUFBSWxCLG9CQUFvQjtJQUMxRHlGLEdBQUcsQ0FBQ3RFLFVBQVUsR0FBR3lGLFFBQVEsR0FBR25CLEdBQUcsQ0FBQ3RFLFVBQVUsR0FBRyxDQUFDLEdBQUcsQ0FBQztJQUNsRHNFLEdBQUcsQ0FBQ3pFLGFBQWEsR0FBR1csR0FBRztJQUN2QjhELEdBQUcsQ0FBQ3hFLFlBQVksR0FBRy9CLEdBQUc7SUFDdEJ1RyxHQUFHLENBQUN2RSxZQUFZLEdBQUcvQixHQUFHO0lBQ3RCLElBQUlzRyxHQUFHLENBQUN0RSxVQUFVLElBQUksQ0FBQyxFQUFFO01BQ3ZCO01BQ0E7TUFDQSxJQUFJc0UsR0FBRyxDQUFDckUscUJBQXFCLEVBQUU7UUFDN0J3QixZQUFZLENBQUM2QyxHQUFHLENBQUNyRSxxQkFBcUIsQ0FBQztRQUN2Q3FFLEdBQUcsQ0FBQ3JFLHFCQUFxQixHQUFHLElBQUk7TUFDbEM7TUFDQTtNQUNBLE1BQU0zQixLQUFLLEdBQUdnRyxHQUFHLENBQUN0RSxVQUFVLEtBQUssQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDO01BQzFDc0UsR0FBRyxDQUFDMUUsS0FBSyxDQUFDdkIsWUFBWSxDQUFDTixHQUFHLEVBQUVDLEdBQUcsRUFBRU0sS0FBSyxDQUFDO01BQ3ZDO0lBQ0Y7SUFDQXpELGNBQWMsQ0FBQ3lLLEdBQUcsRUFBRXZILEdBQUcsRUFBRUMsR0FBRyxDQUFDO0lBQzdCO0lBQ0E7SUFDQTtJQUNBO0lBQ0FzSCxHQUFHLENBQUNNLGVBQWUsR0FBRyxDQUFDUCxDQUFDLENBQUNULE1BQU0sR0FBRyxJQUFJLE1BQU0sQ0FBQztJQUM3Q04sR0FBRyxDQUFDMUUsS0FBSyxDQUFDL0IsaUJBQWlCLENBQUMsQ0FBQztJQUM3QjtFQUNGOztFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBLElBQUkwSCxVQUFVLEtBQUssQ0FBQyxFQUFFO0lBQ3BCLElBQUksQ0FBQ0QsR0FBRyxDQUFDSCxVQUFVLEVBQUU7SUFDckJ6SyxlQUFlLENBQUM0SyxHQUFHLENBQUM7SUFDcEJoQixHQUFHLENBQUMxRSxLQUFLLENBQUMvQixpQkFBaUIsQ0FBQyxDQUFDO0lBQzdCO0VBQ0Y7RUFDQW5ELGVBQWUsQ0FBQzRLLEdBQUcsQ0FBQztFQUNwQjtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQSxJQUFJLENBQUMzSyxZQUFZLENBQUMySyxHQUFHLENBQUMsSUFBSUEsR0FBRyxDQUFDTyxNQUFNLEVBQUU7SUFDcEM7SUFDQTtJQUNBO0lBQ0EsSUFBSSxDQUFDdkIsR0FBRyxDQUFDMUUsS0FBSyxDQUFDOUIsU0FBUyxDQUFDQyxHQUFHLEVBQUVDLEdBQUcsQ0FBQyxFQUFFO01BQ2xDO01BQ0E7TUFDQTtNQUNBLE1BQU1JLEdBQUcsR0FBR2tHLEdBQUcsQ0FBQzFFLEtBQUssQ0FBQzFCLGNBQWMsQ0FBQ0gsR0FBRyxFQUFFQyxHQUFHLENBQUM7TUFDOUM7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBLElBQUlJLEdBQUcsSUFBSXpCLE9BQU8sQ0FBQzBFLEdBQUcsQ0FBQ3lFLFlBQVksS0FBSyxRQUFRLElBQUksQ0FBQ2hMLFNBQVMsQ0FBQyxDQUFDLEVBQUU7UUFDaEU7UUFDQTtRQUNBLElBQUl3SixHQUFHLENBQUNyRSxxQkFBcUIsRUFBRTtVQUM3QndCLFlBQVksQ0FBQzZDLEdBQUcsQ0FBQ3JFLHFCQUFxQixDQUFDO1FBQ3pDO1FBQ0FxRSxHQUFHLENBQUNyRSxxQkFBcUIsR0FBR0UsVUFBVSxDQUNwQyxDQUFDbUUsR0FBRyxFQUFFbEcsR0FBRyxLQUFLO1VBQ1prRyxHQUFHLENBQUNyRSxxQkFBcUIsR0FBRyxJQUFJO1VBQ2hDcUUsR0FBRyxDQUFDMUUsS0FBSyxDQUFDekIsZUFBZSxDQUFDQyxHQUFHLENBQUM7UUFDaEMsQ0FBQyxFQUNEUSxzQkFBc0IsRUFDdEIwRixHQUFHLEVBQ0hsRyxHQUNGLENBQUM7TUFDSDtJQUNGO0VBQ0Y7RUFDQWtHLEdBQUcsQ0FBQzFFLEtBQUssQ0FBQy9CLGlCQUFpQixDQUFDLENBQUM7QUFDL0IiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts b/ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts new file mode 100644 index 0000000000..3d13e779cc --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/AppContext.ts @@ -0,0 +1,20 @@ +import { createContext } from 'react' + +export type Props = { + /** + * Exit (unmount) the whole Ink app. + */ + readonly exit: (error?: Error) => void +} + +/** + * `AppContext` is a React context, which exposes a method to manually exit the app (unmount). + */ + +const AppContext = createContext({ + exit() {} +}) + +AppContext.displayName = 'InternalAppContext' + +export default AppContext diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx new file mode 100644 index 0000000000..13ec469954 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx @@ -0,0 +1,265 @@ +import '../global.d.ts' + +import React, { type Ref } from 'react' +import { c as _c } from 'react/compiler-runtime' +import type { Except } from 'type-fest' + +import type { DOMElement } from '../dom.js' +import type { ClickEvent } from '../events/click-event.js' +import type { FocusEvent } from '../events/focus-event.js' +import type { KeyboardEvent } from '../events/keyboard-event.js' +import type { Styles } from '../styles.js' +import * as warn from '../warn.js' +export type Props = Except & { + ref?: Ref + /** + * Tab order index. Nodes with `tabIndex >= 0` participate in + * Tab/Shift+Tab cycling; `-1` means programmatically focusable only. + */ + tabIndex?: number + /** + * Focus this element when it mounts. Like the HTML `autofocus` + * attribute — the FocusManager calls `focus(node)` during the + * reconciler's `commitMount` phase. + */ + autoFocus?: boolean + /** + * Fired on left-button click (press + release without drag). Only works + * inside `` where mouse tracking is enabled — no-op + * otherwise. The event bubbles from the deepest hit Box up through + * ancestors; call `event.stopImmediatePropagation()` to stop bubbling. + */ + onClick?: (event: ClickEvent) => void + onFocus?: (event: FocusEvent) => void + onFocusCapture?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void + onKeyDown?: (event: KeyboardEvent) => void + onKeyDownCapture?: (event: KeyboardEvent) => void + /** + * Fired when the mouse moves into this Box's rendered rect. Like DOM + * `mouseenter`, does NOT bubble — moving between children does not + * re-fire on the parent. Only works inside `` where + * mode-1003 mouse tracking is enabled. + */ + onMouseEnter?: () => void + /** Fired when the mouse moves out of this Box's rendered rect. */ + onMouseLeave?: () => void +} + +/** + * `` is an essential Ink component to build your layout. It's like `
` in the browser. + */ +function Box(t0) { + const $ = _c(42) + let autoFocus + let children + let flexDirection + let flexGrow + let flexShrink + let flexWrap + let onBlur + let onBlurCapture + let onClick + let onFocus + let onFocusCapture + let onKeyDown + let onKeyDownCapture + let onMouseEnter + let onMouseLeave + let ref + let style + let tabIndex + + if ($[0] !== t0) { + const { + children: t1, + flexWrap: t2, + flexDirection: t3, + flexGrow: t4, + flexShrink: t5, + ref: t6, + tabIndex: t7, + autoFocus: t8, + onClick: t9, + onFocus: t10, + onFocusCapture: t11, + onBlur: t12, + onBlurCapture: t13, + onMouseEnter: t14, + onMouseLeave: t15, + onKeyDown: t16, + onKeyDownCapture: t17, + ...t18 + } = t0 + + children = t1 + ref = t6 + tabIndex = t7 + autoFocus = t8 + onClick = t9 + onFocus = t10 + onFocusCapture = t11 + onBlur = t12 + onBlurCapture = t13 + onMouseEnter = t14 + onMouseLeave = t15 + onKeyDown = t16 + onKeyDownCapture = t17 + style = t18 + flexWrap = t2 === undefined ? 'nowrap' : t2 + flexDirection = t3 === undefined ? 'row' : t3 + flexGrow = t4 === undefined ? 0 : t4 + flexShrink = t5 === undefined ? 1 : t5 + warn.ifNotInteger(style.margin, 'margin') + warn.ifNotInteger(style.marginX, 'marginX') + warn.ifNotInteger(style.marginY, 'marginY') + warn.ifNotInteger(style.marginTop, 'marginTop') + warn.ifNotInteger(style.marginBottom, 'marginBottom') + warn.ifNotInteger(style.marginLeft, 'marginLeft') + warn.ifNotInteger(style.marginRight, 'marginRight') + warn.ifNotInteger(style.padding, 'padding') + warn.ifNotInteger(style.paddingX, 'paddingX') + warn.ifNotInteger(style.paddingY, 'paddingY') + warn.ifNotInteger(style.paddingTop, 'paddingTop') + warn.ifNotInteger(style.paddingBottom, 'paddingBottom') + warn.ifNotInteger(style.paddingLeft, 'paddingLeft') + warn.ifNotInteger(style.paddingRight, 'paddingRight') + warn.ifNotInteger(style.gap, 'gap') + warn.ifNotInteger(style.columnGap, 'columnGap') + warn.ifNotInteger(style.rowGap, 'rowGap') + $[0] = t0 + $[1] = autoFocus + $[2] = children + $[3] = flexDirection + $[4] = flexGrow + $[5] = flexShrink + $[6] = flexWrap + $[7] = onBlur + $[8] = onBlurCapture + $[9] = onClick + $[10] = onFocus + $[11] = onFocusCapture + $[12] = onKeyDown + $[13] = onKeyDownCapture + $[14] = onMouseEnter + $[15] = onMouseLeave + $[16] = ref + $[17] = style + $[18] = tabIndex + } else { + autoFocus = $[1] + children = $[2] + flexDirection = $[3] + flexGrow = $[4] + flexShrink = $[5] + flexWrap = $[6] + onBlur = $[7] + onBlurCapture = $[8] + onClick = $[9] + onFocus = $[10] + onFocusCapture = $[11] + onKeyDown = $[12] + onKeyDownCapture = $[13] + onMouseEnter = $[14] + onMouseLeave = $[15] + ref = $[16] + style = $[17] + tabIndex = $[18] + } + + const t1 = style.overflowX ?? style.overflow ?? 'visible' + const t2 = style.overflowY ?? style.overflow ?? 'visible' + let t3 + + if ( + $[19] !== flexDirection || + $[20] !== flexGrow || + $[21] !== flexShrink || + $[22] !== flexWrap || + $[23] !== style || + $[24] !== t1 || + $[25] !== t2 + ) { + t3 = { + flexWrap, + flexDirection, + flexGrow, + flexShrink, + ...style, + overflowX: t1, + overflowY: t2 + } + $[19] = flexDirection + $[20] = flexGrow + $[21] = flexShrink + $[22] = flexWrap + $[23] = style + $[24] = t1 + $[25] = t2 + $[26] = t3 + } else { + t3 = $[26] + } + + let t4 + + if ( + $[27] !== autoFocus || + $[28] !== children || + $[29] !== onBlur || + $[30] !== onBlurCapture || + $[31] !== onClick || + $[32] !== onFocus || + $[33] !== onFocusCapture || + $[34] !== onKeyDown || + $[35] !== onKeyDownCapture || + $[36] !== onMouseEnter || + $[37] !== onMouseLeave || + $[38] !== ref || + $[39] !== t3 || + $[40] !== tabIndex + ) { + t4 = ( + + {children} + + ) + $[27] = autoFocus + $[28] = children + $[29] = onBlur + $[30] = onBlurCapture + $[31] = onClick + $[32] = onFocus + $[33] = onFocusCapture + $[34] = onKeyDown + $[35] = onKeyDownCapture + $[36] = onMouseEnter + $[37] = onMouseLeave + $[38] = ref + $[39] = t3 + $[40] = tabIndex + $[41] = t4 + } else { + t4 = $[41] + } + + return t4 +} + +export default Box +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiUmVmIiwiRXhjZXB0IiwiRE9NRWxlbWVudCIsIkNsaWNrRXZlbnQiLCJGb2N1c0V2ZW50IiwiS2V5Ym9hcmRFdmVudCIsIlN0eWxlcyIsIndhcm4iLCJQcm9wcyIsInJlZiIsInRhYkluZGV4IiwiYXV0b0ZvY3VzIiwib25DbGljayIsImV2ZW50Iiwib25Gb2N1cyIsIm9uRm9jdXNDYXB0dXJlIiwib25CbHVyIiwib25CbHVyQ2FwdHVyZSIsIm9uS2V5RG93biIsIm9uS2V5RG93bkNhcHR1cmUiLCJvbk1vdXNlRW50ZXIiLCJvbk1vdXNlTGVhdmUiLCJCb3giLCJ0MCIsIiQiLCJfYyIsImNoaWxkcmVuIiwiZmxleERpcmVjdGlvbiIsImZsZXhHcm93IiwiZmxleFNocmluayIsImZsZXhXcmFwIiwic3R5bGUiLCJ0MSIsInQyIiwidDMiLCJ0NCIsInQ1IiwidDYiLCJ0NyIsInQ4IiwidDkiLCJ0MTAiLCJ0MTEiLCJ0MTIiLCJ0MTMiLCJ0MTQiLCJ0MTUiLCJ0MTYiLCJ0MTciLCJ0MTgiLCJ1bmRlZmluZWQiLCJpZk5vdEludGVnZXIiLCJtYXJnaW4iLCJtYXJnaW5YIiwibWFyZ2luWSIsIm1hcmdpblRvcCIsIm1hcmdpbkJvdHRvbSIsIm1hcmdpbkxlZnQiLCJtYXJnaW5SaWdodCIsInBhZGRpbmciLCJwYWRkaW5nWCIsInBhZGRpbmdZIiwicGFkZGluZ1RvcCIsInBhZGRpbmdCb3R0b20iLCJwYWRkaW5nTGVmdCIsInBhZGRpbmdSaWdodCIsImdhcCIsImNvbHVtbkdhcCIsInJvd0dhcCIsIm92ZXJmbG93WCIsIm92ZXJmbG93Iiwib3ZlcmZsb3dZIl0sInNvdXJjZXMiOlsiQm94LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgJy4uL2dsb2JhbC5kLnRzJ1xuaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4sIHR5cGUgUmVmIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IEV4Y2VwdCB9IGZyb20gJ3R5cGUtZmVzdCdcbmltcG9ydCB0eXBlIHsgRE9NRWxlbWVudCB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB0eXBlIHsgQ2xpY2tFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9jbGljay1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgRm9jdXNFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9mb2N1cy1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgS2V5Ym9hcmRFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9rZXlib2FyZC1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgU3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuaW1wb3J0ICogYXMgd2FybiBmcm9tICcuLi93YXJuLmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEV4Y2VwdDxTdHlsZXMsICd0ZXh0V3JhcCc+ICYge1xuICByZWY/OiBSZWY8RE9NRWxlbWVudD5cbiAgLyoqXG4gICAqIFRhYiBvcmRlciBpbmRleC4gTm9kZXMgd2l0aCBgdGFiSW5kZXggPj0gMGAgcGFydGljaXBhdGUgaW5cbiAgICogVGFiL1NoaWZ0K1RhYiBjeWNsaW5nOyBgLTFgIG1lYW5zIHByb2dyYW1tYXRpY2FsbHkgZm9jdXNhYmxlIG9ubHkuXG4gICAqL1xuICB0YWJJbmRleD86IG51bWJlclxuICAvKipcbiAgICogRm9jdXMgdGhpcyBlbGVtZW50IHdoZW4gaXQgbW91bnRzLiBMaWtlIHRoZSBIVE1MIGBhdXRvZm9jdXNgXG4gICAqIGF0dHJpYnV0ZSDigJQgdGhlIEZvY3VzTWFuYWdlciBjYWxscyBgZm9jdXMobm9kZSlgIGR1cmluZyB0aGVcbiAgICogcmVjb25jaWxlcidzIGBjb21taXRNb3VudGAgcGhhc2UuXG4gICAqL1xuICBhdXRvRm9jdXM/OiBib29sZWFuXG4gIC8qKlxuICAgKiBGaXJlZCBvbiBsZWZ0LWJ1dHRvbiBjbGljayAocHJlc3MgKyByZWxlYXNlIHdpdGhvdXQgZHJhZykuIE9ubHkgd29ya3NcbiAgICogaW5zaWRlIGA8QWx0ZXJuYXRlU2NyZWVuPmAgd2hlcmUgbW91c2UgdHJhY2tpbmcgaXMgZW5hYmxlZCDigJQgbm8tb3BcbiAgICogb3RoZXJ3aXNlLiBUaGUgZXZlbnQgYnViYmxlcyBmcm9tIHRoZSBkZWVwZXN0IGhpdCBCb3ggdXAgdGhyb3VnaFxuICAgKiBhbmNlc3RvcnM7IGNhbGwgYGV2ZW50LnN0b3BJbW1lZGlhdGVQcm9wYWdhdGlvbigpYCB0byBzdG9wIGJ1YmJsaW5nLlxuICAgKi9cbiAgb25DbGljaz86IChldmVudDogQ2xpY2tFdmVudCkgPT4gdm9pZFxuICBvbkZvY3VzPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uRm9jdXNDYXB0dXJlPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uQmx1cj86IChldmVudDogRm9jdXNFdmVudCkgPT4gdm9pZFxuICBvbkJsdXJDYXB0dXJlPzogKGV2ZW50OiBGb2N1c0V2ZW50KSA9PiB2b2lkXG4gIG9uS2V5RG93bj86IChldmVudDogS2V5Ym9hcmRFdmVudCkgPT4gdm9pZFxuICBvbktleURvd25DYXB0dXJlPzogKGV2ZW50OiBLZXlib2FyZEV2ZW50KSA9PiB2b2lkXG4gIC8qKlxuICAgKiBGaXJlZCB3aGVuIHRoZSBtb3VzZSBtb3ZlcyBpbnRvIHRoaXMgQm94J3MgcmVuZGVyZWQgcmVjdC4gTGlrZSBET01cbiAgICogYG1vdXNlZW50ZXJgLCBkb2VzIE5PVCBidWJibGUg4oCUIG1vdmluZyBiZXR3ZWVuIGNoaWxkcmVuIGRvZXMgbm90XG4gICAqIHJlLWZpcmUgb24gdGhlIHBhcmVudC4gT25seSB3b3JrcyBpbnNpZGUgYDxBbHRlcm5hdGVTY3JlZW4+YCB3aGVyZVxuICAgKiBtb2RlLTEwMDMgbW91c2UgdHJhY2tpbmcgaXMgZW5hYmxlZC5cbiAgICovXG4gIG9uTW91c2VFbnRlcj86ICgpID0+IHZvaWRcbiAgLyoqIEZpcmVkIHdoZW4gdGhlIG1vdXNlIG1vdmVzIG91dCBvZiB0aGlzIEJveCdzIHJlbmRlcmVkIHJlY3QuICovXG4gIG9uTW91c2VMZWF2ZT86ICgpID0+IHZvaWRcbn1cblxuLyoqXG4gKiBgPEJveD5gIGlzIGFuIGVzc2VudGlhbCBJbmsgY29tcG9uZW50IHRvIGJ1aWxkIHlvdXIgbGF5b3V0LiBJdCdzIGxpa2UgYDxkaXYgc3R5bGU9XCJkaXNwbGF5OiBmbGV4XCI+YCBpbiB0aGUgYnJvd3Nlci5cbiAqL1xuZnVuY3Rpb24gQm94KHtcbiAgY2hpbGRyZW4sXG4gIGZsZXhXcmFwID0gJ25vd3JhcCcsXG4gIGZsZXhEaXJlY3Rpb24gPSAncm93JyxcbiAgZmxleEdyb3cgPSAwLFxuICBmbGV4U2hyaW5rID0gMSxcbiAgcmVmLFxuICB0YWJJbmRleCxcbiAgYXV0b0ZvY3VzLFxuICBvbkNsaWNrLFxuICBvbkZvY3VzLFxuICBvbkZvY3VzQ2FwdHVyZSxcbiAgb25CbHVyLFxuICBvbkJsdXJDYXB0dXJlLFxuICBvbk1vdXNlRW50ZXIsXG4gIG9uTW91c2VMZWF2ZSxcbiAgb25LZXlEb3duLFxuICBvbktleURvd25DYXB0dXJlLFxuICAuLi5zdHlsZVxufTogUHJvcHNXaXRoQ2hpbGRyZW48UHJvcHM+KTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgLy8gV2FybiBpZiBzcGFjaW5nIHZhbHVlcyBhcmUgbm90IGludGVnZXJzIHRvIHByZXZlbnQgZnJhY3Rpb25hbCBsYXlvdXQgZGltZW5zaW9uc1xuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW4sICdtYXJnaW4nKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW5YLCAnbWFyZ2luWCcpXG4gIHdhcm4uaWZOb3RJbnRlZ2VyKHN0eWxlLm1hcmdpblksICdtYXJnaW5ZJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luVG9wLCAnbWFyZ2luVG9wJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luQm90dG9tLCAnbWFyZ2luQm90dG9tJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUubWFyZ2luTGVmdCwgJ21hcmdpbkxlZnQnKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5tYXJnaW5SaWdodCwgJ21hcmdpblJpZ2h0JylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZywgJ3BhZGRpbmcnKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5wYWRkaW5nWCwgJ3BhZGRpbmdYJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZ1ksICdwYWRkaW5nWScpXG4gIHdhcm4uaWZOb3RJbnRlZ2VyKHN0eWxlLnBhZGRpbmdUb3AsICdwYWRkaW5nVG9wJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZ0JvdHRvbSwgJ3BhZGRpbmdCb3R0b20nKVxuICB3YXJuLmlmTm90SW50ZWdlcihzdHlsZS5wYWRkaW5nTGVmdCwgJ3BhZGRpbmdMZWZ0JylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucGFkZGluZ1JpZ2h0LCAncGFkZGluZ1JpZ2h0JylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUuZ2FwLCAnZ2FwJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUuY29sdW1uR2FwLCAnY29sdW1uR2FwJylcbiAgd2Fybi5pZk5vdEludGVnZXIoc3R5bGUucm93R2FwLCAncm93R2FwJylcblxuICByZXR1cm4gKFxuICAgIDxpbmstYm94XG4gICAgICByZWY9e3JlZn1cbiAgICAgIHRhYkluZGV4PXt0YWJJbmRleH1cbiAgICAgIGF1dG9Gb2N1cz17YXV0b0ZvY3VzfVxuICAgICAgb25DbGljaz17b25DbGlja31cbiAgICAgIG9uRm9jdXM9e29uRm9jdXN9XG4gICAgICBvbkZvY3VzQ2FwdHVyZT17b25Gb2N1c0NhcHR1cmV9XG4gICAgICBvbkJsdXI9e29uQmx1cn1cbiAgICAgIG9uQmx1ckNhcHR1cmU9e29uQmx1ckNhcHR1cmV9XG4gICAgICBvbk1vdXNlRW50ZXI9e29uTW91c2VFbnRlcn1cbiAgICAgIG9uTW91c2VMZWF2ZT17b25Nb3VzZUxlYXZlfVxuICAgICAgb25LZXlEb3duPXtvbktleURvd259XG4gICAgICBvbktleURvd25DYXB0dXJlPXtvbktleURvd25DYXB0dXJlfVxuICAgICAgc3R5bGU9e3tcbiAgICAgICAgZmxleFdyYXAsXG4gICAgICAgIGZsZXhEaXJlY3Rpb24sXG4gICAgICAgIGZsZXhHcm93LFxuICAgICAgICBmbGV4U2hyaW5rLFxuICAgICAgICAuLi5zdHlsZSxcbiAgICAgICAgb3ZlcmZsb3dYOiBzdHlsZS5vdmVyZmxvd1ggPz8gc3R5bGUub3ZlcmZsb3cgPz8gJ3Zpc2libGUnLFxuICAgICAgICBvdmVyZmxvd1k6IHN0eWxlLm92ZXJmbG93WSA/PyBzdHlsZS5vdmVyZmxvdyA/PyAndmlzaWJsZScsXG4gICAgICB9fVxuICAgID5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L2luay1ib3g+XG4gIClcbn1cblxuZXhwb3J0IGRlZmF1bHQgQm94XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPLGdCQUFnQjtBQUN2QixPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLEVBQUUsS0FBS0MsR0FBRyxRQUFRLE9BQU87QUFDL0QsY0FBY0MsTUFBTSxRQUFRLFdBQVc7QUFDdkMsY0FBY0MsVUFBVSxRQUFRLFdBQVc7QUFDM0MsY0FBY0MsVUFBVSxRQUFRLDBCQUEwQjtBQUMxRCxjQUFjQyxVQUFVLFFBQVEsMEJBQTBCO0FBQzFELGNBQWNDLGFBQWEsUUFBUSw2QkFBNkI7QUFDaEUsY0FBY0MsTUFBTSxRQUFRLGNBQWM7QUFDMUMsT0FBTyxLQUFLQyxJQUFJLE1BQU0sWUFBWTtBQUVsQyxPQUFPLEtBQUtDLEtBQUssR0FBR1AsTUFBTSxDQUFDSyxNQUFNLEVBQUUsVUFBVSxDQUFDLEdBQUc7RUFDL0NHLEdBQUcsQ0FBQyxFQUFFVCxHQUFHLENBQUNFLFVBQVUsQ0FBQztFQUNyQjtBQUNGO0FBQ0E7QUFDQTtFQUNFUSxRQUFRLENBQUMsRUFBRSxNQUFNO0VBQ2pCO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsU0FBUyxDQUFDLEVBQUUsT0FBTztFQUNuQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsT0FBTyxDQUFDLEVBQUUsQ0FBQ0MsS0FBSyxFQUFFVixVQUFVLEVBQUUsR0FBRyxJQUFJO0VBQ3JDVyxPQUFPLENBQUMsRUFBRSxDQUFDRCxLQUFLLEVBQUVULFVBQVUsRUFBRSxHQUFHLElBQUk7RUFDckNXLGNBQWMsQ0FBQyxFQUFFLENBQUNGLEtBQUssRUFBRVQsVUFBVSxFQUFFLEdBQUcsSUFBSTtFQUM1Q1ksTUFBTSxDQUFDLEVBQUUsQ0FBQ0gsS0FBSyxFQUFFVCxVQUFVLEVBQUUsR0FBRyxJQUFJO0VBQ3BDYSxhQUFhLENBQUMsRUFBRSxDQUFDSixLQUFLLEVBQUVULFVBQVUsRUFBRSxHQUFHLElBQUk7RUFDM0NjLFNBQVMsQ0FBQyxFQUFFLENBQUNMLEtBQUssRUFBRVIsYUFBYSxFQUFFLEdBQUcsSUFBSTtFQUMxQ2MsZ0JBQWdCLENBQUMsRUFBRSxDQUFDTixLQUFLLEVBQUVSLGFBQWEsRUFBRSxHQUFHLElBQUk7RUFDakQ7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VlLFlBQVksQ0FBQyxFQUFFLEdBQUcsR0FBRyxJQUFJO0VBQ3pCO0VBQ0FDLFlBQVksQ0FBQyxFQUFFLEdBQUcsR0FBRyxJQUFJO0FBQzNCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0EsU0FBQUMsSUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFBLElBQUFkLFNBQUE7RUFBQSxJQUFBZSxRQUFBO0VBQUEsSUFBQUMsYUFBQTtFQUFBLElBQUFDLFFBQUE7RUFBQSxJQUFBQyxVQUFBO0VBQUEsSUFBQUMsUUFBQTtFQUFBLElBQUFkLE1BQUE7RUFBQSxJQUFBQyxhQUFBO0VBQUEsSUFBQUwsT0FBQTtFQUFBLElBQUFFLE9BQUE7RUFBQSxJQUFBQyxjQUFBO0VBQUEsSUFBQUcsU0FBQTtFQUFBLElBQUFDLGdCQUFBO0VBQUEsSUFBQUMsWUFBQTtFQUFBLElBQUFDLFlBQUE7RUFBQSxJQUFBWixHQUFBO0VBQUEsSUFBQXNCLEtBQUE7RUFBQSxJQUFBckIsUUFBQTtFQUFBLElBQUFjLENBQUEsUUFBQUQsRUFBQTtJQUFhO01BQUFHLFFBQUEsRUFBQU0sRUFBQTtNQUFBRixRQUFBLEVBQUFHLEVBQUE7TUFBQU4sYUFBQSxFQUFBTyxFQUFBO01BQUFOLFFBQUEsRUFBQU8sRUFBQTtNQUFBTixVQUFBLEVBQUFPLEVBQUE7TUFBQTNCLEdBQUEsRUFBQTRCLEVBQUE7TUFBQTNCLFFBQUEsRUFBQTRCLEVBQUE7TUFBQTNCLFNBQUEsRUFBQTRCLEVBQUE7TUFBQTNCLE9BQUEsRUFBQTRCLEVBQUE7TUFBQTFCLE9BQUEsRUFBQTJCLEdBQUE7TUFBQTFCLGNBQUEsRUFBQTJCLEdBQUE7TUFBQTFCLE1BQUEsRUFBQTJCLEdBQUE7TUFBQTFCLGFBQUEsRUFBQTJCLEdBQUE7TUFBQXhCLFlBQUEsRUFBQXlCLEdBQUE7TUFBQXhCLFlBQUEsRUFBQXlCLEdBQUE7TUFBQTVCLFNBQUEsRUFBQTZCLEdBQUE7TUFBQTVCLGdCQUFBLEVBQUE2QixHQUFBO01BQUEsR0FBQUM7SUFBQSxJQUFBMUIsRUFtQmM7SUFuQmRHLFFBQUEsR0FBQU0sRUFBQTtJQUFBdkIsR0FBQSxHQUFBNEIsRUFBQTtJQUFBM0IsUUFBQSxHQUFBNEIsRUFBQTtJQUFBM0IsU0FBQSxHQUFBNEIsRUFBQTtJQUFBM0IsT0FBQSxHQUFBNEIsRUFBQTtJQUFBMUIsT0FBQSxHQUFBMkIsR0FBQTtJQUFBMUIsY0FBQSxHQUFBMkIsR0FBQTtJQUFBMUIsTUFBQSxHQUFBMkIsR0FBQTtJQUFBMUIsYUFBQSxHQUFBMkIsR0FBQTtJQUFBeEIsWUFBQSxHQUFBeUIsR0FBQTtJQUFBeEIsWUFBQSxHQUFBeUIsR0FBQTtJQUFBNUIsU0FBQSxHQUFBNkIsR0FBQTtJQUFBNUIsZ0JBQUEsR0FBQTZCLEdBQUE7SUFBQWpCLEtBQUEsR0FBQWtCLEdBQUE7SUFFWG5CLFFBQUEsR0FBQUcsRUFBbUIsS0FBbkJpQixTQUFtQixHQUFuQixRQUFtQixHQUFuQmpCLEVBQW1CO0lBQ25CTixhQUFBLEdBQUFPLEVBQXFCLEtBQXJCZ0IsU0FBcUIsR0FBckIsS0FBcUIsR0FBckJoQixFQUFxQjtJQUNyQk4sUUFBQSxHQUFBTyxFQUFZLEtBQVplLFNBQVksR0FBWixDQUFZLEdBQVpmLEVBQVk7SUFDWk4sVUFBQSxHQUFBTyxFQUFjLEtBQWRjLFNBQWMsR0FBZCxDQUFjLEdBQWRkLEVBQWM7SUFnQmQ3QixJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUFxQixNQUFPLEVBQUUsUUFBUSxDQUFDO0lBQ3pDN0MsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBc0IsT0FBUSxFQUFFLFNBQVMsQ0FBQztJQUMzQzlDLElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQXVCLE9BQVEsRUFBRSxTQUFTLENBQUM7SUFDM0MvQyxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUF3QixTQUFVLEVBQUUsV0FBVyxDQUFDO0lBQy9DaEQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBeUIsWUFBYSxFQUFFLGNBQWMsQ0FBQztJQUNyRGpELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQTBCLFVBQVcsRUFBRSxZQUFZLENBQUM7SUFDakRsRCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUEyQixXQUFZLEVBQUUsYUFBYSxDQUFDO0lBQ25EbkQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBNEIsT0FBUSxFQUFFLFNBQVMsQ0FBQztJQUMzQ3BELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQTZCLFFBQVMsRUFBRSxVQUFVLENBQUM7SUFDN0NyRCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUE4QixRQUFTLEVBQUUsVUFBVSxDQUFDO0lBQzdDdEQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBK0IsVUFBVyxFQUFFLFlBQVksQ0FBQztJQUNqRHZELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQWdDLGFBQWMsRUFBRSxlQUFlLENBQUM7SUFDdkR4RCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUFpQyxXQUFZLEVBQUUsYUFBYSxDQUFDO0lBQ25EekQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBa0MsWUFBYSxFQUFFLGNBQWMsQ0FBQztJQUNyRDFELElBQUksQ0FBQTRDLFlBQWEsQ0FBQ3BCLEtBQUssQ0FBQW1DLEdBQUksRUFBRSxLQUFLLENBQUM7SUFDbkMzRCxJQUFJLENBQUE0QyxZQUFhLENBQUNwQixLQUFLLENBQUFvQyxTQUFVLEVBQUUsV0FBVyxDQUFDO0lBQy9DNUQsSUFBSSxDQUFBNEMsWUFBYSxDQUFDcEIsS0FBSyxDQUFBcUMsTUFBTyxFQUFFLFFBQVEsQ0FBQztJQUFBNUMsQ0FBQSxNQUFBRCxFQUFBO0lBQUFDLENBQUEsTUFBQWIsU0FBQTtJQUFBYSxDQUFBLE1BQUFFLFFBQUE7SUFBQUYsQ0FBQSxNQUFBRyxhQUFBO0lBQUFILENBQUEsTUFBQUksUUFBQTtJQUFBSixDQUFBLE1BQUFLLFVBQUE7SUFBQUwsQ0FBQSxNQUFBTSxRQUFBO0lBQUFOLENBQUEsTUFBQVIsTUFBQTtJQUFBUSxDQUFBLE1BQUFQLGFBQUE7SUFBQU8sQ0FBQSxNQUFBWixPQUFBO0lBQUFZLENBQUEsT0FBQVYsT0FBQTtJQUFBVSxDQUFBLE9BQUFULGNBQUE7SUFBQVMsQ0FBQSxPQUFBTixTQUFBO0lBQUFNLENBQUEsT0FBQUwsZ0JBQUE7SUFBQUssQ0FBQSxPQUFBSixZQUFBO0lBQUFJLENBQUEsT0FBQUgsWUFBQTtJQUFBRyxDQUFBLE9BQUFmLEdBQUE7SUFBQWUsQ0FBQSxPQUFBTyxLQUFBO0lBQUFQLENBQUEsT0FBQWQsUUFBQTtFQUFBO0lBQUFDLFNBQUEsR0FBQWEsQ0FBQTtJQUFBRSxRQUFBLEdBQUFGLENBQUE7SUFBQUcsYUFBQSxHQUFBSCxDQUFBO0lBQUFJLFFBQUEsR0FBQUosQ0FBQTtJQUFBSyxVQUFBLEdBQUFMLENBQUE7SUFBQU0sUUFBQSxHQUFBTixDQUFBO0lBQUFSLE1BQUEsR0FBQVEsQ0FBQTtJQUFBUCxhQUFBLEdBQUFPLENBQUE7SUFBQVosT0FBQSxHQUFBWSxDQUFBO0lBQUFWLE9BQUEsR0FBQVUsQ0FBQTtJQUFBVCxjQUFBLEdBQUFTLENBQUE7SUFBQU4sU0FBQSxHQUFBTSxDQUFBO0lBQUFMLGdCQUFBLEdBQUFLLENBQUE7SUFBQUosWUFBQSxHQUFBSSxDQUFBO0lBQUFILFlBQUEsR0FBQUcsQ0FBQTtJQUFBZixHQUFBLEdBQUFlLENBQUE7SUFBQU8sS0FBQSxHQUFBUCxDQUFBO0lBQUFkLFFBQUEsR0FBQWMsQ0FBQTtFQUFBO0VBc0J4QixNQUFBUSxFQUFBLEdBQUFELEtBQUssQ0FBQXNDLFNBQTRCLElBQWR0QyxLQUFLLENBQUF1QyxRQUFzQixJQUE5QyxTQUE4QztFQUM5QyxNQUFBckMsRUFBQSxHQUFBRixLQUFLLENBQUF3QyxTQUE0QixJQUFkeEMsS0FBSyxDQUFBdUMsUUFBc0IsSUFBOUMsU0FBOEM7RUFBQSxJQUFBcEMsRUFBQTtFQUFBLElBQUFWLENBQUEsU0FBQUcsYUFBQSxJQUFBSCxDQUFBLFNBQUFJLFFBQUEsSUFBQUosQ0FBQSxTQUFBSyxVQUFBLElBQUFMLENBQUEsU0FBQU0sUUFBQSxJQUFBTixDQUFBLFNBQUFPLEtBQUEsSUFBQVAsQ0FBQSxTQUFBUSxFQUFBLElBQUFSLENBQUEsU0FBQVMsRUFBQTtJQVBwREMsRUFBQTtNQUFBSixRQUFBO01BQUFILGFBQUE7TUFBQUMsUUFBQTtNQUFBQyxVQUFBO01BQUEsR0FLRkUsS0FBSztNQUFBc0MsU0FBQSxFQUNHckMsRUFBOEM7TUFBQXVDLFNBQUEsRUFDOUN0QztJQUNiLENBQUM7SUFBQVQsQ0FBQSxPQUFBRyxhQUFBO0lBQUFILENBQUEsT0FBQUksUUFBQTtJQUFBSixDQUFBLE9BQUFLLFVBQUE7SUFBQUwsQ0FBQSxPQUFBTSxRQUFBO0lBQUFOLENBQUEsT0FBQU8sS0FBQTtJQUFBUCxDQUFBLE9BQUFRLEVBQUE7SUFBQVIsQ0FBQSxPQUFBUyxFQUFBO0lBQUFULENBQUEsT0FBQVUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQVYsQ0FBQTtFQUFBO0VBQUEsSUFBQVcsRUFBQTtFQUFBLElBQUFYLENBQUEsU0FBQWIsU0FBQSxJQUFBYSxDQUFBLFNBQUFFLFFBQUEsSUFBQUYsQ0FBQSxTQUFBUixNQUFBLElBQUFRLENBQUEsU0FBQVAsYUFBQSxJQUFBTyxDQUFBLFNBQUFaLE9BQUEsSUFBQVksQ0FBQSxTQUFBVixPQUFBLElBQUFVLENBQUEsU0FBQVQsY0FBQSxJQUFBUyxDQUFBLFNBQUFOLFNBQUEsSUFBQU0sQ0FBQSxTQUFBTCxnQkFBQSxJQUFBSyxDQUFBLFNBQUFKLFlBQUEsSUFBQUksQ0FBQSxTQUFBSCxZQUFBLElBQUFHLENBQUEsU0FBQWYsR0FBQSxJQUFBZSxDQUFBLFNBQUFVLEVBQUEsSUFBQVYsQ0FBQSxTQUFBZCxRQUFBO0lBckJIeUIsRUFBQSxXQXdCVSxDQXZCSDFCLEdBQUcsQ0FBSEEsSUFBRSxDQUFDLENBQ0VDLFFBQVEsQ0FBUkEsU0FBTyxDQUFDLENBQ1BDLFNBQVMsQ0FBVEEsVUFBUSxDQUFDLENBQ1hDLE9BQU8sQ0FBUEEsUUFBTSxDQUFDLENBQ1BFLE9BQU8sQ0FBUEEsUUFBTSxDQUFDLENBQ0FDLGNBQWMsQ0FBZEEsZUFBYSxDQUFDLENBQ3RCQyxNQUFNLENBQU5BLE9BQUssQ0FBQyxDQUNDQyxhQUFhLENBQWJBLGNBQVksQ0FBQyxDQUNkRyxZQUFZLENBQVpBLGFBQVcsQ0FBQyxDQUNaQyxZQUFZLENBQVpBLGFBQVcsQ0FBQyxDQUNmSCxTQUFTLENBQVRBLFVBQVEsQ0FBQyxDQUNGQyxnQkFBZ0IsQ0FBaEJBLGlCQUFlLENBQUMsQ0FDM0IsS0FRTixDQVJNLENBQUFlLEVBUVAsQ0FBQyxDQUVBUixTQUFPLENBQ1YsRUF4QkEsT0F3QlU7SUFBQUYsQ0FBQSxPQUFBYixTQUFBO0lBQUFhLENBQUEsT0FBQUUsUUFBQTtJQUFBRixDQUFBLE9BQUFSLE1BQUE7SUFBQVEsQ0FBQSxPQUFBUCxhQUFBO0lBQUFPLENBQUEsT0FBQVosT0FBQTtJQUFBWSxDQUFBLE9BQUFWLE9BQUE7SUFBQVUsQ0FBQSxPQUFBVCxjQUFBO0lBQUFTLENBQUEsT0FBQU4sU0FBQTtJQUFBTSxDQUFBLE9BQUFMLGdCQUFBO0lBQUFLLENBQUEsT0FBQUosWUFBQTtJQUFBSSxDQUFBLE9BQUFILFlBQUE7SUFBQUcsQ0FBQSxPQUFBZixHQUFBO0lBQUFlLENBQUEsT0FBQVUsRUFBQTtJQUFBVixDQUFBLE9BQUFkLFFBQUE7SUFBQWMsQ0FBQSxPQUFBVyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBWCxDQUFBO0VBQUE7RUFBQSxPQXhCVlcsRUF3QlU7QUFBQTtBQUlkLGVBQWViLEdBQUciLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Button.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Button.tsx new file mode 100644 index 0000000000..e99034c6db --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/Button.tsx @@ -0,0 +1,236 @@ +import React, { type Ref, useEffect, useRef, useState } from 'react' +import { c as _c } from 'react/compiler-runtime' +import type { Except } from 'type-fest' + +import type { DOMElement } from '../dom.js' +import type { Styles } from '../styles.js' + +import Box from './Box.js' +type ButtonState = { + focused: boolean + hovered: boolean + active: boolean +} +export type Props = Except & { + ref?: Ref + /** + * Called when the button is activated via Enter, Space, or click. + */ + onAction: () => void + /** + * Tab order index. Defaults to 0 (in tab order). + * Set to -1 for programmatically focusable only. + */ + tabIndex?: number + /** + * Focus this button when it mounts. + */ + autoFocus?: boolean + /** + * Render prop receiving the interactive state. Use this to + * style children based on focus/hover/active — Button itself + * is intentionally unstyled. + * + * If not provided, children render as-is (no state-dependent styling). + */ + children: ((state: ButtonState) => React.ReactNode) | React.ReactNode +} + +function Button(t0) { + const $ = _c(30) + let autoFocus + let children + let onAction + let ref + let style + let t1 + + if ($[0] !== t0) { + ;({ onAction, tabIndex: t1, autoFocus, children, ref, ...style } = t0) + $[0] = t0 + $[1] = autoFocus + $[2] = children + $[3] = onAction + $[4] = ref + $[5] = style + $[6] = t1 + } else { + autoFocus = $[1] + children = $[2] + onAction = $[3] + ref = $[4] + style = $[5] + t1 = $[6] + } + + const tabIndex = t1 === undefined ? 0 : t1 + const [isFocused, setIsFocused] = useState(false) + const [isHovered, setIsHovered] = useState(false) + const [isActive, setIsActive] = useState(false) + const activeTimer = useRef(null) + let t2 + let t3 + + if ($[7] === Symbol.for('react.memo_cache_sentinel')) { + t2 = () => () => { + if (activeTimer.current) { + clearTimeout(activeTimer.current) + } + } + + t3 = [] + $[7] = t2 + $[8] = t3 + } else { + t2 = $[7] + t3 = $[8] + } + + useEffect(t2, t3) + let t4 + + if ($[9] !== onAction) { + t4 = e => { + if (e.key === 'return' || e.key === ' ') { + e.preventDefault() + setIsActive(true) + onAction() + + if (activeTimer.current) { + clearTimeout(activeTimer.current) + } + + activeTimer.current = setTimeout(_temp, 100, setIsActive) + } + } + + $[9] = onAction + $[10] = t4 + } else { + t4 = $[10] + } + + const handleKeyDown = t4 + let t5 + + if ($[11] !== onAction) { + t5 = _e => { + onAction() + } + + $[11] = onAction + $[12] = t5 + } else { + t5 = $[12] + } + + const handleClick = t5 + let t6 + + if ($[13] === Symbol.for('react.memo_cache_sentinel')) { + t6 = _e_0 => setIsFocused(true) + $[13] = t6 + } else { + t6 = $[13] + } + + const handleFocus = t6 + let t7 + + if ($[14] === Symbol.for('react.memo_cache_sentinel')) { + t7 = _e_1 => setIsFocused(false) + $[14] = t7 + } else { + t7 = $[14] + } + + const handleBlur = t7 + let t8 + + if ($[15] === Symbol.for('react.memo_cache_sentinel')) { + t8 = () => setIsHovered(true) + $[15] = t8 + } else { + t8 = $[15] + } + + const handleMouseEnter = t8 + let t9 + + if ($[16] === Symbol.for('react.memo_cache_sentinel')) { + t9 = () => setIsHovered(false) + $[16] = t9 + } else { + t9 = $[16] + } + + const handleMouseLeave = t9 + let t10 + + if ($[17] !== children || $[18] !== isActive || $[19] !== isFocused || $[20] !== isHovered) { + const state = { + focused: isFocused, + hovered: isHovered, + active: isActive + } + + t10 = typeof children === 'function' ? children(state) : children + $[17] = children + $[18] = isActive + $[19] = isFocused + $[20] = isHovered + $[21] = t10 + } else { + t10 = $[21] + } + + const content = t10 + let t11 + + if ( + $[22] !== autoFocus || + $[23] !== content || + $[24] !== handleClick || + $[25] !== handleKeyDown || + $[26] !== ref || + $[27] !== style || + $[28] !== tabIndex + ) { + t11 = ( + + {content} + + ) + $[22] = autoFocus + $[23] = content + $[24] = handleClick + $[25] = handleKeyDown + $[26] = ref + $[27] = style + $[28] = tabIndex + $[29] = t11 + } else { + t11 = $[29] + } + + return t11 +} + +function _temp(setter) { + return setter(false) +} + +export default Button +export type { ButtonState } +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlJlZiIsInVzZUNhbGxiYWNrIiwidXNlRWZmZWN0IiwidXNlUmVmIiwidXNlU3RhdGUiLCJFeGNlcHQiLCJET01FbGVtZW50IiwiQ2xpY2tFdmVudCIsIkZvY3VzRXZlbnQiLCJLZXlib2FyZEV2ZW50IiwiU3R5bGVzIiwiQm94IiwiQnV0dG9uU3RhdGUiLCJmb2N1c2VkIiwiaG92ZXJlZCIsImFjdGl2ZSIsIlByb3BzIiwicmVmIiwib25BY3Rpb24iLCJ0YWJJbmRleCIsImF1dG9Gb2N1cyIsImNoaWxkcmVuIiwic3RhdGUiLCJSZWFjdE5vZGUiLCJCdXR0b24iLCJ0MCIsIiQiLCJfYyIsInN0eWxlIiwidDEiLCJ1bmRlZmluZWQiLCJpc0ZvY3VzZWQiLCJzZXRJc0ZvY3VzZWQiLCJpc0hvdmVyZWQiLCJzZXRJc0hvdmVyZWQiLCJpc0FjdGl2ZSIsInNldElzQWN0aXZlIiwiYWN0aXZlVGltZXIiLCJ0MiIsInQzIiwiU3ltYm9sIiwiZm9yIiwiY3VycmVudCIsImNsZWFyVGltZW91dCIsInQ0IiwiZSIsImtleSIsInByZXZlbnREZWZhdWx0Iiwic2V0VGltZW91dCIsIl90ZW1wIiwiaGFuZGxlS2V5RG93biIsInQ1IiwiX2UiLCJoYW5kbGVDbGljayIsInQ2IiwiX2VfMCIsImhhbmRsZUZvY3VzIiwidDciLCJfZV8xIiwiaGFuZGxlQmx1ciIsInQ4IiwiaGFuZGxlTW91c2VFbnRlciIsInQ5IiwiaGFuZGxlTW91c2VMZWF2ZSIsInQxMCIsImNvbnRlbnQiLCJ0MTEiLCJzZXR0ZXIiXSwic291cmNlcyI6WyJCdXR0b24udHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCwge1xuICB0eXBlIFJlZixcbiAgdXNlQ2FsbGJhY2ssXG4gIHVzZUVmZmVjdCxcbiAgdXNlUmVmLFxuICB1c2VTdGF0ZSxcbn0gZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IEV4Y2VwdCB9IGZyb20gJ3R5cGUtZmVzdCdcbmltcG9ydCB0eXBlIHsgRE9NRWxlbWVudCB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB0eXBlIHsgQ2xpY2tFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9jbGljay1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgRm9jdXNFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9mb2N1cy1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgS2V5Ym9hcmRFdmVudCB9IGZyb20gJy4uL2V2ZW50cy9rZXlib2FyZC1ldmVudC5qcydcbmltcG9ydCB0eXBlIHsgU3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuaW1wb3J0IEJveCBmcm9tICcuL0JveC5qcydcblxudHlwZSBCdXR0b25TdGF0ZSA9IHtcbiAgZm9jdXNlZDogYm9vbGVhblxuICBob3ZlcmVkOiBib29sZWFuXG4gIGFjdGl2ZTogYm9vbGVhblxufVxuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEV4Y2VwdDxTdHlsZXMsICd0ZXh0V3JhcCc+ICYge1xuICByZWY/OiBSZWY8RE9NRWxlbWVudD5cbiAgLyoqXG4gICAqIENhbGxlZCB3aGVuIHRoZSBidXR0b24gaXMgYWN0aXZhdGVkIHZpYSBFbnRlciwgU3BhY2UsIG9yIGNsaWNrLlxuICAgKi9cbiAgb25BY3Rpb246ICgpID0+IHZvaWRcbiAgLyoqXG4gICAqIFRhYiBvcmRlciBpbmRleC4gRGVmYXVsdHMgdG8gMCAoaW4gdGFiIG9yZGVyKS5cbiAgICogU2V0IHRvIC0xIGZvciBwcm9ncmFtbWF0aWNhbGx5IGZvY3VzYWJsZSBvbmx5LlxuICAgKi9cbiAgdGFiSW5kZXg/OiBudW1iZXJcbiAgLyoqXG4gICAqIEZvY3VzIHRoaXMgYnV0dG9uIHdoZW4gaXQgbW91bnRzLlxuICAgKi9cbiAgYXV0b0ZvY3VzPzogYm9vbGVhblxuICAvKipcbiAgICogUmVuZGVyIHByb3AgcmVjZWl2aW5nIHRoZSBpbnRlcmFjdGl2ZSBzdGF0ZS4gVXNlIHRoaXMgdG9cbiAgICogc3R5bGUgY2hpbGRyZW4gYmFzZWQgb24gZm9jdXMvaG92ZXIvYWN0aXZlIOKAlCBCdXR0b24gaXRzZWxmXG4gICAqIGlzIGludGVudGlvbmFsbHkgdW5zdHlsZWQuXG4gICAqXG4gICAqIElmIG5vdCBwcm92aWRlZCwgY2hpbGRyZW4gcmVuZGVyIGFzLWlzIChubyBzdGF0ZS1kZXBlbmRlbnQgc3R5bGluZykuXG4gICAqL1xuICBjaGlsZHJlbjogKChzdGF0ZTogQnV0dG9uU3RhdGUpID0+IFJlYWN0LlJlYWN0Tm9kZSkgfCBSZWFjdC5SZWFjdE5vZGVcbn1cblxuZnVuY3Rpb24gQnV0dG9uKHtcbiAgb25BY3Rpb24sXG4gIHRhYkluZGV4ID0gMCxcbiAgYXV0b0ZvY3VzLFxuICBjaGlsZHJlbixcbiAgcmVmLFxuICAuLi5zdHlsZVxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBbaXNGb2N1c2VkLCBzZXRJc0ZvY3VzZWRdID0gdXNlU3RhdGUoZmFsc2UpXG4gIGNvbnN0IFtpc0hvdmVyZWQsIHNldElzSG92ZXJlZF0gPSB1c2VTdGF0ZShmYWxzZSlcbiAgY29uc3QgW2lzQWN0aXZlLCBzZXRJc0FjdGl2ZV0gPSB1c2VTdGF0ZShmYWxzZSlcblxuICBjb25zdCBhY3RpdmVUaW1lciA9IHVzZVJlZjxSZXR1cm5UeXBlPHR5cGVvZiBzZXRUaW1lb3V0PiB8IG51bGw+KG51bGwpXG5cbiAgdXNlRWZmZWN0KCgpID0+IHtcbiAgICByZXR1cm4gKCkgPT4ge1xuICAgICAgaWYgKGFjdGl2ZVRpbWVyLmN1cnJlbnQpIGNsZWFyVGltZW91dChhY3RpdmVUaW1lci5jdXJyZW50KVxuICAgIH1cbiAgfSwgW10pXG5cbiAgY29uc3QgaGFuZGxlS2V5RG93biA9IHVzZUNhbGxiYWNrKFxuICAgIChlOiBLZXlib2FyZEV2ZW50KSA9PiB7XG4gICAgICBpZiAoZS5rZXkgPT09ICdyZXR1cm4nIHx8IGUua2V5ID09PSAnICcpIHtcbiAgICAgICAgZS5wcmV2ZW50RGVmYXVsdCgpXG4gICAgICAgIHNldElzQWN0aXZlKHRydWUpXG4gICAgICAgIG9uQWN0aW9uKClcbiAgICAgICAgaWYgKGFjdGl2ZVRpbWVyLmN1cnJlbnQpIGNsZWFyVGltZW91dChhY3RpdmVUaW1lci5jdXJyZW50KVxuICAgICAgICBhY3RpdmVUaW1lci5jdXJyZW50ID0gc2V0VGltZW91dChcbiAgICAgICAgICBzZXR0ZXIgPT4gc2V0dGVyKGZhbHNlKSxcbiAgICAgICAgICAxMDAsXG4gICAgICAgICAgc2V0SXNBY3RpdmUsXG4gICAgICAgIClcbiAgICAgIH1cbiAgICB9LFxuICAgIFtvbkFjdGlvbl0sXG4gIClcblxuICBjb25zdCBoYW5kbGVDbGljayA9IHVzZUNhbGxiYWNrKFxuICAgIChfZTogQ2xpY2tFdmVudCkgPT4ge1xuICAgICAgb25BY3Rpb24oKVxuICAgIH0sXG4gICAgW29uQWN0aW9uXSxcbiAgKVxuXG4gIGNvbnN0IGhhbmRsZUZvY3VzID0gdXNlQ2FsbGJhY2soKF9lOiBGb2N1c0V2ZW50KSA9PiBzZXRJc0ZvY3VzZWQodHJ1ZSksIFtdKVxuICBjb25zdCBoYW5kbGVCbHVyID0gdXNlQ2FsbGJhY2soKF9lOiBGb2N1c0V2ZW50KSA9PiBzZXRJc0ZvY3VzZWQoZmFsc2UpLCBbXSlcbiAgY29uc3QgaGFuZGxlTW91c2VFbnRlciA9IHVzZUNhbGxiYWNrKCgpID0+IHNldElzSG92ZXJlZCh0cnVlKSwgW10pXG4gIGNvbnN0IGhhbmRsZU1vdXNlTGVhdmUgPSB1c2VDYWxsYmFjaygoKSA9PiBzZXRJc0hvdmVyZWQoZmFsc2UpLCBbXSlcblxuICBjb25zdCBzdGF0ZTogQnV0dG9uU3RhdGUgPSB7XG4gICAgZm9jdXNlZDogaXNGb2N1c2VkLFxuICAgIGhvdmVyZWQ6IGlzSG92ZXJlZCxcbiAgICBhY3RpdmU6IGlzQWN0aXZlLFxuICB9XG4gIGNvbnN0IGNvbnRlbnQgPSB0eXBlb2YgY2hpbGRyZW4gPT09ICdmdW5jdGlvbicgPyBjaGlsZHJlbihzdGF0ZSkgOiBjaGlsZHJlblxuXG4gIHJldHVybiAoXG4gICAgPEJveFxuICAgICAgcmVmPXtyZWZ9XG4gICAgICB0YWJJbmRleD17dGFiSW5kZXh9XG4gICAgICBhdXRvRm9jdXM9e2F1dG9Gb2N1c31cbiAgICAgIG9uS2V5RG93bj17aGFuZGxlS2V5RG93bn1cbiAgICAgIG9uQ2xpY2s9e2hhbmRsZUNsaWNrfVxuICAgICAgb25Gb2N1cz17aGFuZGxlRm9jdXN9XG4gICAgICBvbkJsdXI9e2hhbmRsZUJsdXJ9XG4gICAgICBvbk1vdXNlRW50ZXI9e2hhbmRsZU1vdXNlRW50ZXJ9XG4gICAgICBvbk1vdXNlTGVhdmU9e2hhbmRsZU1vdXNlTGVhdmV9XG4gICAgICB7Li4uc3R5bGV9XG4gICAgPlxuICAgICAge2NvbnRlbnR9XG4gICAgPC9Cb3g+XG4gIClcbn1cblxuZXhwb3J0IGRlZmF1bHQgQnV0dG9uXG5leHBvcnQgdHlwZSB7IEJ1dHRvblN0YXRlIH1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU9BLEtBQUssSUFDVixLQUFLQyxHQUFHLEVBQ1JDLFdBQVcsRUFDWEMsU0FBUyxFQUNUQyxNQUFNLEVBQ05DLFFBQVEsUUFDSCxPQUFPO0FBQ2QsY0FBY0MsTUFBTSxRQUFRLFdBQVc7QUFDdkMsY0FBY0MsVUFBVSxRQUFRLFdBQVc7QUFDM0MsY0FBY0MsVUFBVSxRQUFRLDBCQUEwQjtBQUMxRCxjQUFjQyxVQUFVLFFBQVEsMEJBQTBCO0FBQzFELGNBQWNDLGFBQWEsUUFBUSw2QkFBNkI7QUFDaEUsY0FBY0MsTUFBTSxRQUFRLGNBQWM7QUFDMUMsT0FBT0MsR0FBRyxNQUFNLFVBQVU7QUFFMUIsS0FBS0MsV0FBVyxHQUFHO0VBQ2pCQyxPQUFPLEVBQUUsT0FBTztFQUNoQkMsT0FBTyxFQUFFLE9BQU87RUFDaEJDLE1BQU0sRUFBRSxPQUFPO0FBQ2pCLENBQUM7QUFFRCxPQUFPLEtBQUtDLEtBQUssR0FBR1gsTUFBTSxDQUFDSyxNQUFNLEVBQUUsVUFBVSxDQUFDLEdBQUc7RUFDL0NPLEdBQUcsQ0FBQyxFQUFFakIsR0FBRyxDQUFDTSxVQUFVLENBQUM7RUFDckI7QUFDRjtBQUNBO0VBQ0VZLFFBQVEsRUFBRSxHQUFHLEdBQUcsSUFBSTtFQUNwQjtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxRQUFRLENBQUMsRUFBRSxNQUFNO0VBQ2pCO0FBQ0Y7QUFDQTtFQUNFQyxTQUFTLENBQUMsRUFBRSxPQUFPO0VBQ25CO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VDLFFBQVEsRUFBRSxDQUFDLENBQUNDLEtBQUssRUFBRVYsV0FBVyxFQUFFLEdBQUdiLEtBQUssQ0FBQ3dCLFNBQVMsQ0FBQyxHQUFHeEIsS0FBSyxDQUFDd0IsU0FBUztBQUN2RSxDQUFDO0FBRUQsU0FBQUMsT0FBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFBLElBQUFQLFNBQUE7RUFBQSxJQUFBQyxRQUFBO0VBQUEsSUFBQUgsUUFBQTtFQUFBLElBQUFELEdBQUE7RUFBQSxJQUFBVyxLQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUQsRUFBQTtJQUFnQjtNQUFBUCxRQUFBO01BQUFDLFFBQUEsRUFBQVUsRUFBQTtNQUFBVCxTQUFBO01BQUFDLFFBQUE7TUFBQUosR0FBQTtNQUFBLEdBQUFXO0lBQUEsSUFBQUgsRUFPUjtJQUFBQyxDQUFBLE1BQUFELEVBQUE7SUFBQUMsQ0FBQSxNQUFBTixTQUFBO0lBQUFNLENBQUEsTUFBQUwsUUFBQTtJQUFBSyxDQUFBLE1BQUFSLFFBQUE7SUFBQVEsQ0FBQSxNQUFBVCxHQUFBO0lBQUFTLENBQUEsTUFBQUUsS0FBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7RUFBQTtJQUFBVCxTQUFBLEdBQUFNLENBQUE7SUFBQUwsUUFBQSxHQUFBSyxDQUFBO0lBQUFSLFFBQUEsR0FBQVEsQ0FBQTtJQUFBVCxHQUFBLEdBQUFTLENBQUE7SUFBQUUsS0FBQSxHQUFBRixDQUFBO0lBQUFHLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBTE4sTUFBQVAsUUFBQSxHQUFBVSxFQUFZLEtBQVpDLFNBQVksR0FBWixDQUFZLEdBQVpELEVBQVk7RUFNWixPQUFBRSxTQUFBLEVBQUFDLFlBQUEsSUFBa0M1QixRQUFRLENBQUMsS0FBSyxDQUFDO0VBQ2pELE9BQUE2QixTQUFBLEVBQUFDLFlBQUEsSUFBa0M5QixRQUFRLENBQUMsS0FBSyxDQUFDO0VBQ2pELE9BQUErQixRQUFBLEVBQUFDLFdBQUEsSUFBZ0NoQyxRQUFRLENBQUMsS0FBSyxDQUFDO0VBRS9DLE1BQUFpQyxXQUFBLEdBQW9CbEMsTUFBTSxDQUF1QyxJQUFJLENBQUM7RUFBQSxJQUFBbUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBYixDQUFBLFFBQUFjLE1BQUEsQ0FBQUMsR0FBQTtJQUU1REgsRUFBQSxHQUFBQSxDQUFBLEtBQ0Q7TUFDTCxJQUFJRCxXQUFXLENBQUFLLE9BQVE7UUFBRUMsWUFBWSxDQUFDTixXQUFXLENBQUFLLE9BQVEsQ0FBQztNQUFBO0lBQUEsQ0FFN0Q7SUFBRUgsRUFBQSxLQUFFO0lBQUFiLENBQUEsTUFBQVksRUFBQTtJQUFBWixDQUFBLE1BQUFhLEVBQUE7RUFBQTtJQUFBRCxFQUFBLEdBQUFaLENBQUE7SUFBQWEsRUFBQSxHQUFBYixDQUFBO0VBQUE7RUFKTHhCLFNBQVMsQ0FBQ29DLEVBSVQsRUFBRUMsRUFBRSxDQUFDO0VBQUEsSUFBQUssRUFBQTtFQUFBLElBQUFsQixDQUFBLFFBQUFSLFFBQUE7SUFHSjBCLEVBQUEsR0FBQUMsQ0FBQTtNQUNFLElBQUlBLENBQUMsQ0FBQUMsR0FBSSxLQUFLLFFBQXlCLElBQWJELENBQUMsQ0FBQUMsR0FBSSxLQUFLLEdBQUc7UUFDckNELENBQUMsQ0FBQUUsY0FBZSxDQUFDLENBQUM7UUFDbEJYLFdBQVcsQ0FBQyxJQUFJLENBQUM7UUFDakJsQixRQUFRLENBQUMsQ0FBQztRQUNWLElBQUltQixXQUFXLENBQUFLLE9BQVE7VUFBRUMsWUFBWSxDQUFDTixXQUFXLENBQUFLLE9BQVEsQ0FBQztRQUFBO1FBQzFETCxXQUFXLENBQUFLLE9BQUEsR0FBV00sVUFBVSxDQUM5QkMsS0FBdUIsRUFDdkIsR0FBRyxFQUNIYixXQUNGLENBSm1CO01BQUE7SUFLcEIsQ0FDRjtJQUFBVixDQUFBLE1BQUFSLFFBQUE7SUFBQVEsQ0FBQSxPQUFBa0IsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQWxCLENBQUE7RUFBQTtFQWJILE1BQUF3QixhQUFBLEdBQXNCTixFQWVyQjtFQUFBLElBQUFPLEVBQUE7RUFBQSxJQUFBekIsQ0FBQSxTQUFBUixRQUFBO0lBR0NpQyxFQUFBLEdBQUFDLEVBQUE7TUFDRWxDLFFBQVEsQ0FBQyxDQUFDO0lBQUEsQ0FDWDtJQUFBUSxDQUFBLE9BQUFSLFFBQUE7SUFBQVEsQ0FBQSxPQUFBeUIsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQXpCLENBQUE7RUFBQTtFQUhILE1BQUEyQixXQUFBLEdBQW9CRixFQUtuQjtFQUFBLElBQUFHLEVBQUE7RUFBQSxJQUFBNUIsQ0FBQSxTQUFBYyxNQUFBLENBQUFDLEdBQUE7SUFFK0JhLEVBQUEsR0FBQUMsSUFBQSxJQUFvQnZCLFlBQVksQ0FBQyxJQUFJLENBQUM7SUFBQU4sQ0FBQSxPQUFBNEIsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQTVCLENBQUE7RUFBQTtFQUF0RSxNQUFBOEIsV0FBQSxHQUFvQkYsRUFBdUQ7RUFBQSxJQUFBRyxFQUFBO0VBQUEsSUFBQS9CLENBQUEsU0FBQWMsTUFBQSxDQUFBQyxHQUFBO0lBQzVDZ0IsRUFBQSxHQUFBQyxJQUFBLElBQW9CMUIsWUFBWSxDQUFDLEtBQUssQ0FBQztJQUFBTixDQUFBLE9BQUErQixFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBL0IsQ0FBQTtFQUFBO0VBQXRFLE1BQUFpQyxVQUFBLEdBQW1CRixFQUF3RDtFQUFBLElBQUFHLEVBQUE7RUFBQSxJQUFBbEMsQ0FBQSxTQUFBYyxNQUFBLENBQUFDLEdBQUE7SUFDdENtQixFQUFBLEdBQUFBLENBQUEsS0FBTTFCLFlBQVksQ0FBQyxJQUFJLENBQUM7SUFBQVIsQ0FBQSxPQUFBa0MsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQWxDLENBQUE7RUFBQTtFQUE3RCxNQUFBbUMsZ0JBQUEsR0FBeUJELEVBQXlDO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFwQyxDQUFBLFNBQUFjLE1BQUEsQ0FBQUMsR0FBQTtJQUM3QnFCLEVBQUEsR0FBQUEsQ0FBQSxLQUFNNUIsWUFBWSxDQUFDLEtBQUssQ0FBQztJQUFBUixDQUFBLE9BQUFvQyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBcEMsQ0FBQTtFQUFBO0VBQTlELE1BQUFxQyxnQkFBQSxHQUF5QkQsRUFBMEM7RUFBQSxJQUFBRSxHQUFBO0VBQUEsSUFBQXRDLENBQUEsU0FBQUwsUUFBQSxJQUFBSyxDQUFBLFNBQUFTLFFBQUEsSUFBQVQsQ0FBQSxTQUFBSyxTQUFBLElBQUFMLENBQUEsU0FBQU8sU0FBQTtJQUVuRSxNQUFBWCxLQUFBLEdBQTJCO01BQUFULE9BQUEsRUFDaEJrQixTQUFTO01BQUFqQixPQUFBLEVBQ1RtQixTQUFTO01BQUFsQixNQUFBLEVBQ1ZvQjtJQUNWLENBQUM7SUFDZTZCLEdBQUEsVUFBTzNDLFFBQVEsS0FBSyxVQUF1QyxHQUExQkEsUUFBUSxDQUFDQyxLQUFnQixDQUFDLEdBQTNERCxRQUEyRDtJQUFBSyxDQUFBLE9BQUFMLFFBQUE7SUFBQUssQ0FBQSxPQUFBUyxRQUFBO0lBQUFULENBQUEsT0FBQUssU0FBQTtJQUFBTCxDQUFBLE9BQUFPLFNBQUE7SUFBQVAsQ0FBQSxPQUFBc0MsR0FBQTtFQUFBO0lBQUFBLEdBQUEsR0FBQXRDLENBQUE7RUFBQTtFQUEzRSxNQUFBdUMsT0FBQSxHQUFnQkQsR0FBMkQ7RUFBQSxJQUFBRSxHQUFBO0VBQUEsSUFBQXhDLENBQUEsU0FBQU4sU0FBQSxJQUFBTSxDQUFBLFNBQUF1QyxPQUFBLElBQUF2QyxDQUFBLFNBQUEyQixXQUFBLElBQUEzQixDQUFBLFNBQUF3QixhQUFBLElBQUF4QixDQUFBLFNBQUFULEdBQUEsSUFBQVMsQ0FBQSxTQUFBRSxLQUFBLElBQUFGLENBQUEsU0FBQVAsUUFBQTtJQUd6RStDLEdBQUEsSUFBQyxHQUFHLENBQ0dqRCxHQUFHLENBQUhBLElBQUUsQ0FBQyxDQUNFRSxRQUFRLENBQVJBLFNBQU8sQ0FBQyxDQUNQQyxTQUFTLENBQVRBLFVBQVEsQ0FBQyxDQUNUOEIsU0FBYSxDQUFiQSxjQUFZLENBQUMsQ0FDZkcsT0FBVyxDQUFYQSxZQUFVLENBQUMsQ0FDWEcsT0FBVyxDQUFYQSxZQUFVLENBQUMsQ0FDWkcsTUFBVSxDQUFWQSxXQUFTLENBQUMsQ0FDSkUsWUFBZ0IsQ0FBaEJBLGlCQUFlLENBQUMsQ0FDaEJFLFlBQWdCLENBQWhCQSxpQkFBZSxDQUFDLEtBQzFCbkMsS0FBSyxFQUVScUMsUUFBTSxDQUNULEVBYkMsR0FBRyxDQWFFO0lBQUF2QyxDQUFBLE9BQUFOLFNBQUE7SUFBQU0sQ0FBQSxPQUFBdUMsT0FBQTtJQUFBdkMsQ0FBQSxPQUFBMkIsV0FBQTtJQUFBM0IsQ0FBQSxPQUFBd0IsYUFBQTtJQUFBeEIsQ0FBQSxPQUFBVCxHQUFBO0lBQUFTLENBQUEsT0FBQUUsS0FBQTtJQUFBRixDQUFBLE9BQUFQLFFBQUE7SUFBQU8sQ0FBQSxPQUFBd0MsR0FBQTtFQUFBO0lBQUFBLEdBQUEsR0FBQXhDLENBQUE7RUFBQTtFQUFBLE9BYk53QyxHQWFNO0FBQUE7QUF0RVYsU0FBQWpCLE1BQUFrQixNQUFBO0VBQUEsT0E0Qm9CQSxNQUFNLENBQUMsS0FBSyxDQUFDO0FBQUE7QUE4Q2pDLGVBQWUzQyxNQUFNO0FBQ3JCLGNBQWNaLFdBQVciLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx new file mode 100644 index 0000000000..521cd57513 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx @@ -0,0 +1,133 @@ +import React, { createContext, useEffect, useState } from 'react' +import { c as _c } from 'react/compiler-runtime' + +import { BLURRED_FRAME_INTERVAL_MS, FRAME_INTERVAL_MS } from '../constants.js' +import { useTerminalFocus } from '../hooks/use-terminal-focus.js' +export type Clock = { + subscribe: (onChange: () => void, keepAlive: boolean) => () => void + now: () => number + setTickInterval: (ms: number) => void +} + +export function createClock(tickIntervalMs: number): Clock { + const subscribers = new Map<() => void, boolean>() + let interval: ReturnType | null = null + let currentTickIntervalMs = tickIntervalMs + let startTime = 0 + // Snapshot of the current tick's time, ensuring all subscribers in the same + // tick see the same value (keeps animations synchronized) + let tickTime = 0 + + function tick(): void { + tickTime = Date.now() - startTime + + for (const onChange of subscribers.keys()) { + onChange() + } + } + + function updateInterval(): void { + const anyKeepAlive = [...subscribers.values()].some(Boolean) + + if (anyKeepAlive) { + if (interval) { + clearInterval(interval) + interval = null + } + + if (startTime === 0) { + startTime = Date.now() + } + + interval = setInterval(tick, currentTickIntervalMs) + } else if (interval) { + clearInterval(interval) + interval = null + } + } + + return { + subscribe(onChange, keepAlive) { + subscribers.set(onChange, keepAlive) + updateInterval() + + return () => { + subscribers.delete(onChange) + updateInterval() + } + }, + now() { + if (startTime === 0) { + startTime = Date.now() + } + + // When the clock interval is running, return the synchronized tickTime + // so all subscribers in the same tick see the same value. + // When paused (no keepAlive subscribers), return real-time to avoid + // returning a stale tickTime from the last tick before the pause. + if (interval && tickTime) { + return tickTime + } + + return Date.now() - startTime + }, + setTickInterval(ms) { + if (ms === currentTickIntervalMs) { + return + } + + currentTickIntervalMs = ms + updateInterval() + } + } +} + +export const ClockContext = createContext(null) + +// Own component so App.tsx doesn't re-render when the clock is created. +// The clock value is stable (created once via useState), so the provider +// never causes consumer re-renders on its own. +export function ClockProvider(t0) { + const $ = _c(7) + + const { children } = t0 + + const [clock] = useState(_temp) + const focused = useTerminalFocus() + let t1 + let t2 + + if ($[0] !== clock || $[1] !== focused) { + t1 = () => { + clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_FRAME_INTERVAL_MS) + } + + t2 = [clock, focused] + $[0] = clock + $[1] = focused + $[2] = t1 + $[3] = t2 + } else { + t1 = $[2] + t2 = $[3] + } + + useEffect(t1, t2) + let t3 + + if ($[4] !== children || $[5] !== clock) { + t3 = {children} + $[4] = children + $[5] = clock + $[6] = t3 + } else { + t3 = $[6] + } + + return t3 +} + +function _temp() { + return createClock(FRAME_INTERVAL_MS) +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VFZmZlY3QiLCJ1c2VTdGF0ZSIsIkZSQU1FX0lOVEVSVkFMX01TIiwidXNlVGVybWluYWxGb2N1cyIsIkNsb2NrIiwic3Vic2NyaWJlIiwib25DaGFuZ2UiLCJrZWVwQWxpdmUiLCJub3ciLCJzZXRUaWNrSW50ZXJ2YWwiLCJtcyIsImNyZWF0ZUNsb2NrIiwidGlja0ludGVydmFsTXMiLCJzdWJzY3JpYmVycyIsIk1hcCIsImludGVydmFsIiwiUmV0dXJuVHlwZSIsInNldEludGVydmFsIiwiY3VycmVudFRpY2tJbnRlcnZhbE1zIiwic3RhcnRUaW1lIiwidGlja1RpbWUiLCJ0aWNrIiwiRGF0ZSIsImtleXMiLCJ1cGRhdGVJbnRlcnZhbCIsImFueUtlZXBBbGl2ZSIsInZhbHVlcyIsInNvbWUiLCJCb29sZWFuIiwiY2xlYXJJbnRlcnZhbCIsInNldCIsImRlbGV0ZSIsIkNsb2NrQ29udGV4dCIsIkJMVVJSRURfVElDS19JTlRFUlZBTF9NUyIsIkNsb2NrUHJvdmlkZXIiLCJ0MCIsIiQiLCJfYyIsImNoaWxkcmVuIiwiY2xvY2siLCJfdGVtcCIsImZvY3VzZWQiLCJ0MSIsInQyIiwidDMiXSwic291cmNlcyI6WyJDbG9ja0NvbnRleHQudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBSZWFjdCwgeyBjcmVhdGVDb250ZXh0LCB1c2VFZmZlY3QsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBGUkFNRV9JTlRFUlZBTF9NUyB9IGZyb20gJy4uL2NvbnN0YW50cy5qcydcbmltcG9ydCB7IHVzZVRlcm1pbmFsRm9jdXMgfSBmcm9tICcuLi9ob29rcy91c2UtdGVybWluYWwtZm9jdXMuanMnXG5cbmV4cG9ydCB0eXBlIENsb2NrID0ge1xuICBzdWJzY3JpYmU6IChvbkNoYW5nZTogKCkgPT4gdm9pZCwga2VlcEFsaXZlOiBib29sZWFuKSA9PiAoKSA9PiB2b2lkXG4gIG5vdzogKCkgPT4gbnVtYmVyXG4gIHNldFRpY2tJbnRlcnZhbDogKG1zOiBudW1iZXIpID0+IHZvaWRcbn1cblxuZXhwb3J0IGZ1bmN0aW9uIGNyZWF0ZUNsb2NrKHRpY2tJbnRlcnZhbE1zOiBudW1iZXIpOiBDbG9jayB7XG4gIGNvbnN0IHN1YnNjcmliZXJzID0gbmV3IE1hcDwoKSA9PiB2b2lkLCBib29sZWFuPigpXG4gIGxldCBpbnRlcnZhbDogUmV0dXJuVHlwZTx0eXBlb2Ygc2V0SW50ZXJ2YWw+IHwgbnVsbCA9IG51bGxcbiAgbGV0IGN1cnJlbnRUaWNrSW50ZXJ2YWxNcyA9IHRpY2tJbnRlcnZhbE1zXG4gIGxldCBzdGFydFRpbWUgPSAwXG4gIC8vIFNuYXBzaG90IG9mIHRoZSBjdXJyZW50IHRpY2sncyB0aW1lLCBlbnN1cmluZyBhbGwgc3Vic2NyaWJlcnMgaW4gdGhlIHNhbWVcbiAgLy8gdGljayBzZWUgdGhlIHNhbWUgdmFsdWUgKGtlZXBzIGFuaW1hdGlvbnMgc3luY2hyb25pemVkKVxuICBsZXQgdGlja1RpbWUgPSAwXG5cbiAgZnVuY3Rpb24gdGljaygpOiB2b2lkIHtcbiAgICB0aWNrVGltZSA9IERhdGUubm93KCkgLSBzdGFydFRpbWVcbiAgICBmb3IgKGNvbnN0IG9uQ2hhbmdlIG9mIHN1YnNjcmliZXJzLmtleXMoKSkge1xuICAgICAgb25DaGFuZ2UoKVxuICAgIH1cbiAgfVxuXG4gIGZ1bmN0aW9uIHVwZGF0ZUludGVydmFsKCk6IHZvaWQge1xuICAgIGNvbnN0IGFueUtlZXBBbGl2ZSA9IFsuLi5zdWJzY3JpYmVycy52YWx1ZXMoKV0uc29tZShCb29sZWFuKVxuXG4gICAgaWYgKGFueUtlZXBBbGl2ZSkge1xuICAgICAgaWYgKGludGVydmFsKSB7XG4gICAgICAgIGNsZWFySW50ZXJ2YWwoaW50ZXJ2YWwpXG4gICAgICAgIGludGVydmFsID0gbnVsbFxuICAgICAgfVxuICAgICAgaWYgKHN0YXJ0VGltZSA9PT0gMCkge1xuICAgICAgICBzdGFydFRpbWUgPSBEYXRlLm5vdygpXG4gICAgICB9XG4gICAgICBpbnRlcnZhbCA9IHNldEludGVydmFsKHRpY2ssIGN1cnJlbnRUaWNrSW50ZXJ2YWxNcylcbiAgICB9IGVsc2UgaWYgKGludGVydmFsKSB7XG4gICAgICBjbGVhckludGVydmFsKGludGVydmFsKVxuICAgICAgaW50ZXJ2YWwgPSBudWxsXG4gICAgfVxuICB9XG5cbiAgcmV0dXJuIHtcbiAgICBzdWJzY3JpYmUob25DaGFuZ2UsIGtlZXBBbGl2ZSkge1xuICAgICAgc3Vic2NyaWJlcnMuc2V0KG9uQ2hhbmdlLCBrZWVwQWxpdmUpXG4gICAgICB1cGRhdGVJbnRlcnZhbCgpXG4gICAgICByZXR1cm4gKCkgPT4ge1xuICAgICAgICBzdWJzY3JpYmVycy5kZWxldGUob25DaGFuZ2UpXG4gICAgICAgIHVwZGF0ZUludGVydmFsKClcbiAgICAgIH1cbiAgICB9LFxuXG4gICAgbm93KCkge1xuICAgICAgaWYgKHN0YXJ0VGltZSA9PT0gMCkge1xuICAgICAgICBzdGFydFRpbWUgPSBEYXRlLm5vdygpXG4gICAgICB9XG4gICAgICAvLyBXaGVuIHRoZSBjbG9jayBpbnRlcnZhbCBpcyBydW5uaW5nLCByZXR1cm4gdGhlIHN5bmNocm9uaXplZCB0aWNrVGltZVxuICAgICAgLy8gc28gYWxsIHN1YnNjcmliZXJzIGluIHRoZSBzYW1lIHRpY2sgc2VlIHRoZSBzYW1lIHZhbHVlLlxuICAgICAgLy8gV2hlbiBwYXVzZWQgKG5vIGtlZXBBbGl2ZSBzdWJzY3JpYmVycyksIHJldHVybiByZWFsLXRpbWUgdG8gYXZvaWRcbiAgICAgIC8vIHJldHVybmluZyBhIHN0YWxlIHRpY2tUaW1lIGZyb20gdGhlIGxhc3QgdGljayBiZWZvcmUgdGhlIHBhdXNlLlxuICAgICAgaWYgKGludGVydmFsICYmIHRpY2tUaW1lKSB7XG4gICAgICAgIHJldHVybiB0aWNrVGltZVxuICAgICAgfVxuICAgICAgcmV0dXJuIERhdGUubm93KCkgLSBzdGFydFRpbWVcbiAgICB9LFxuXG4gICAgc2V0VGlja0ludGVydmFsKG1zKSB7XG4gICAgICBpZiAobXMgPT09IGN1cnJlbnRUaWNrSW50ZXJ2YWxNcykgcmV0dXJuXG4gICAgICBjdXJyZW50VGlja0ludGVydmFsTXMgPSBtc1xuICAgICAgdXBkYXRlSW50ZXJ2YWwoKVxuICAgIH0sXG4gIH1cbn1cblxuZXhwb3J0IGNvbnN0IENsb2NrQ29udGV4dCA9IGNyZWF0ZUNvbnRleHQ8Q2xvY2sgfCBudWxsPihudWxsKVxuXG5jb25zdCBCTFVSUkVEX1RJQ0tfSU5URVJWQUxfTVMgPSBGUkFNRV9JTlRFUlZBTF9NUyAqIDJcblxuLy8gT3duIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIHdoZW4gdGhlIGNsb2NrIGlzIGNyZWF0ZWQuXG4vLyBUaGUgY2xvY2sgdmFsdWUgaXMgc3RhYmxlIChjcmVhdGVkIG9uY2UgdmlhIHVzZVN0YXRlKSwgc28gdGhlIHByb3ZpZGVyXG4vLyBuZXZlciBjYXVzZXMgY29uc3VtZXIgcmUtcmVuZGVycyBvbiBpdHMgb3duLlxuZXhwb3J0IGZ1bmN0aW9uIENsb2NrUHJvdmlkZXIoe1xuICBjaGlsZHJlbixcbn06IHtcbiAgY2hpbGRyZW46IFJlYWN0LlJlYWN0Tm9kZVxufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGNvbnN0IFtjbG9ja10gPSB1c2VTdGF0ZSgoKSA9PiBjcmVhdGVDbG9jayhGUkFNRV9JTlRFUlZBTF9NUykpXG4gIGNvbnN0IGZvY3VzZWQgPSB1c2VUZXJtaW5hbEZvY3VzKClcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGNsb2NrLnNldFRpY2tJbnRlcnZhbChcbiAgICAgIGZvY3VzZWQgPyBGUkFNRV9JTlRFUlZBTF9NUyA6IEJMVVJSRURfVElDS19JTlRFUlZBTF9NUyxcbiAgICApXG4gIH0sIFtjbG9jaywgZm9jdXNlZF0pXG5cbiAgcmV0dXJuIDxDbG9ja0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e2Nsb2NrfT57Y2hpbGRyZW59PC9DbG9ja0NvbnRleHQuUHJvdmlkZXI+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsU0FBUyxFQUFFQyxRQUFRLFFBQVEsT0FBTztBQUNqRSxTQUFTQyxpQkFBaUIsUUFBUSxpQkFBaUI7QUFDbkQsU0FBU0MsZ0JBQWdCLFFBQVEsZ0NBQWdDO0FBRWpFLE9BQU8sS0FBS0MsS0FBSyxHQUFHO0VBQ2xCQyxTQUFTLEVBQUUsQ0FBQ0MsUUFBUSxFQUFFLEdBQUcsR0FBRyxJQUFJLEVBQUVDLFNBQVMsRUFBRSxPQUFPLEVBQUUsR0FBRyxHQUFHLEdBQUcsSUFBSTtFQUNuRUMsR0FBRyxFQUFFLEdBQUcsR0FBRyxNQUFNO0VBQ2pCQyxlQUFlLEVBQUUsQ0FBQ0MsRUFBRSxFQUFFLE1BQU0sRUFBRSxHQUFHLElBQUk7QUFDdkMsQ0FBQztBQUVELE9BQU8sU0FBU0MsV0FBV0EsQ0FBQ0MsY0FBYyxFQUFFLE1BQU0sQ0FBQyxFQUFFUixLQUFLLENBQUM7RUFDekQsTUFBTVMsV0FBVyxHQUFHLElBQUlDLEdBQUcsQ0FBQyxHQUFHLEdBQUcsSUFBSSxFQUFFLE9BQU8sQ0FBQyxDQUFDLENBQUM7RUFDbEQsSUFBSUMsUUFBUSxFQUFFQyxVQUFVLENBQUMsT0FBT0MsV0FBVyxDQUFDLEdBQUcsSUFBSSxHQUFHLElBQUk7RUFDMUQsSUFBSUMscUJBQXFCLEdBQUdOLGNBQWM7RUFDMUMsSUFBSU8sU0FBUyxHQUFHLENBQUM7RUFDakI7RUFDQTtFQUNBLElBQUlDLFFBQVEsR0FBRyxDQUFDO0VBRWhCLFNBQVNDLElBQUlBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUNwQkQsUUFBUSxHQUFHRSxJQUFJLENBQUNkLEdBQUcsQ0FBQyxDQUFDLEdBQUdXLFNBQVM7SUFDakMsS0FBSyxNQUFNYixRQUFRLElBQUlPLFdBQVcsQ0FBQ1UsSUFBSSxDQUFDLENBQUMsRUFBRTtNQUN6Q2pCLFFBQVEsQ0FBQyxDQUFDO0lBQ1o7RUFDRjtFQUVBLFNBQVNrQixjQUFjQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDOUIsTUFBTUMsWUFBWSxHQUFHLENBQUMsR0FBR1osV0FBVyxDQUFDYSxNQUFNLENBQUMsQ0FBQyxDQUFDLENBQUNDLElBQUksQ0FBQ0MsT0FBTyxDQUFDO0lBRTVELElBQUlILFlBQVksRUFBRTtNQUNoQixJQUFJVixRQUFRLEVBQUU7UUFDWmMsYUFBYSxDQUFDZCxRQUFRLENBQUM7UUFDdkJBLFFBQVEsR0FBRyxJQUFJO01BQ2pCO01BQ0EsSUFBSUksU0FBUyxLQUFLLENBQUMsRUFBRTtRQUNuQkEsU0FBUyxHQUFHRyxJQUFJLENBQUNkLEdBQUcsQ0FBQyxDQUFDO01BQ3hCO01BQ0FPLFFBQVEsR0FBR0UsV0FBVyxDQUFDSSxJQUFJLEVBQUVILHFCQUFxQixDQUFDO0lBQ3JELENBQUMsTUFBTSxJQUFJSCxRQUFRLEVBQUU7TUFDbkJjLGFBQWEsQ0FBQ2QsUUFBUSxDQUFDO01BQ3ZCQSxRQUFRLEdBQUcsSUFBSTtJQUNqQjtFQUNGO0VBRUEsT0FBTztJQUNMVixTQUFTQSxDQUFDQyxRQUFRLEVBQUVDLFNBQVMsRUFBRTtNQUM3Qk0sV0FBVyxDQUFDaUIsR0FBRyxDQUFDeEIsUUFBUSxFQUFFQyxTQUFTLENBQUM7TUFDcENpQixjQUFjLENBQUMsQ0FBQztNQUNoQixPQUFPLE1BQU07UUFDWFgsV0FBVyxDQUFDa0IsTUFBTSxDQUFDekIsUUFBUSxDQUFDO1FBQzVCa0IsY0FBYyxDQUFDLENBQUM7TUFDbEIsQ0FBQztJQUNILENBQUM7SUFFRGhCLEdBQUdBLENBQUEsRUFBRztNQUNKLElBQUlXLFNBQVMsS0FBSyxDQUFDLEVBQUU7UUFDbkJBLFNBQVMsR0FBR0csSUFBSSxDQUFDZCxHQUFHLENBQUMsQ0FBQztNQUN4QjtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0EsSUFBSU8sUUFBUSxJQUFJSyxRQUFRLEVBQUU7UUFDeEIsT0FBT0EsUUFBUTtNQUNqQjtNQUNBLE9BQU9FLElBQUksQ0FBQ2QsR0FBRyxDQUFDLENBQUMsR0FBR1csU0FBUztJQUMvQixDQUFDO0lBRURWLGVBQWVBLENBQUNDLEVBQUUsRUFBRTtNQUNsQixJQUFJQSxFQUFFLEtBQUtRLHFCQUFxQixFQUFFO01BQ2xDQSxxQkFBcUIsR0FBR1IsRUFBRTtNQUMxQmMsY0FBYyxDQUFDLENBQUM7SUFDbEI7RUFDRixDQUFDO0FBQ0g7QUFFQSxPQUFPLE1BQU1RLFlBQVksR0FBR2pDLGFBQWEsQ0FBQ0ssS0FBSyxHQUFHLElBQUksQ0FBQyxDQUFDLElBQUksQ0FBQztBQUU3RCxNQUFNNkIsd0JBQXdCLEdBQUcvQixpQkFBaUIsR0FBRyxDQUFDOztBQUV0RDtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQUFnQyxjQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQXVCO0lBQUFDO0VBQUEsSUFBQUgsRUFJN0I7RUFDQyxPQUFBSSxLQUFBLElBQWdCdEMsUUFBUSxDQUFDdUMsS0FBb0MsQ0FBQztFQUM5RCxNQUFBQyxPQUFBLEdBQWdCdEMsZ0JBQWdCLENBQUMsQ0FBQztFQUFBLElBQUF1QyxFQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFQLENBQUEsUUFBQUcsS0FBQSxJQUFBSCxDQUFBLFFBQUFLLE9BQUE7SUFFeEJDLEVBQUEsR0FBQUEsQ0FBQTtNQUNSSCxLQUFLLENBQUE5QixlQUFnQixDQUNuQmdDLE9BQU8sR0FBUHZDLGlCQUFzRCxHQUF0RCtCLHdCQUNGLENBQUM7SUFBQSxDQUNGO0lBQUVVLEVBQUEsSUFBQ0osS0FBSyxFQUFFRSxPQUFPLENBQUM7SUFBQUwsQ0FBQSxNQUFBRyxLQUFBO0lBQUFILENBQUEsTUFBQUssT0FBQTtJQUFBTCxDQUFBLE1BQUFNLEVBQUE7SUFBQU4sQ0FBQSxNQUFBTyxFQUFBO0VBQUE7SUFBQUQsRUFBQSxHQUFBTixDQUFBO0lBQUFPLEVBQUEsR0FBQVAsQ0FBQTtFQUFBO0VBSm5CcEMsU0FBUyxDQUFDMEMsRUFJVCxFQUFFQyxFQUFnQixDQUFDO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFSLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFHLEtBQUE7SUFFYkssRUFBQSwwQkFBOEJMLEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQUdELFNBQU8sQ0FBRSx3QkFBd0I7SUFBQUYsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsS0FBQTtJQUFBSCxDQUFBLE1BQUFRLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFSLENBQUE7RUFBQTtFQUFBLE9BQXZFUSxFQUF1RTtBQUFBO0FBZHpFLFNBQUFKLE1BQUE7RUFBQSxPQUswQjdCLFdBQVcsQ0FBQ1QsaUJBQWlCLENBQUM7QUFBQSIsImlnbm9yZUxpc3QiOltdfQ== diff --git a/ui-tui/packages/hermes-ink/src/ink/components/CursorDeclarationContext.ts b/ui-tui/packages/hermes-ink/src/ink/components/CursorDeclarationContext.ts new file mode 100644 index 0000000000..37356afa17 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/CursorDeclarationContext.ts @@ -0,0 +1,28 @@ +import { createContext } from 'react' + +import type { DOMElement } from '../dom.js' + +export type CursorDeclaration = { + /** Display column (terminal cell width) within the declared node */ + readonly relativeX: number + /** Line number within the declared node */ + readonly relativeY: number + /** The ink-box DOMElement whose yoga layout provides the absolute origin */ + readonly node: DOMElement +} + +/** + * Setter for the declared cursor position. + * + * The optional second argument makes `null` a conditional clear: the + * declaration is only cleared if the currently-declared node matches + * `clearIfNode`. This makes the hook safe for sibling components + * (e.g. list items) that transfer focus among themselves — without the + * node check, a newly-unfocused item's clear could clobber a + * newly-focused sibling's set depending on layout-effect order. + */ +export type CursorDeclarationSetter = (declaration: CursorDeclaration | null, clearIfNode?: DOMElement | null) => void + +const CursorDeclarationContext = createContext(() => {}) + +export default CursorDeclarationContext diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx new file mode 100644 index 0000000000..9e87788e6c --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/ErrorOverview.tsx @@ -0,0 +1,130 @@ +import { readFileSync } from 'fs' + +import codeExcerpt, { type CodeExcerpt } from 'code-excerpt' +import React from 'react' +import StackUtils from 'stack-utils' + +import Box from './Box.js' +import Text from './Text.js' + +// Error's source file is reported as file:///home/user/file.js +// This function removes the file://[cwd] part +const cleanupPath = (path: string | undefined): string | undefined => { + return path?.replace(`file://${process.cwd()}/`, '') +} + +let stackUtils: StackUtils | undefined + +function getStackUtils(): StackUtils { + return (stackUtils ??= new StackUtils({ + cwd: process.cwd(), + internals: StackUtils.nodeInternals() + })) +} + +type Props = { + readonly error: Error +} + +export default function ErrorOverview({ error }: Props) { + const stack = error.stack ? error.stack.split('\n').slice(1) : undefined + const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined + const filePath = cleanupPath(origin?.file) + let excerpt: CodeExcerpt[] | undefined + let lineWidth = 0 + + if (filePath && origin?.line) { + try { + const sourceCode = readFileSync(filePath, 'utf8') + excerpt = codeExcerpt(sourceCode, origin.line) + + if (excerpt) { + for (const { line } of excerpt) { + lineWidth = Math.max(lineWidth, String(line).length) + } + } + } catch { + // file not readable — skip source context + } + } + + return ( + + + + {' '} + ERROR{' '} + + + {error.message} + + + {origin && filePath && ( + + + {filePath}:{origin.line}:{origin.column} + + + )} + + {origin && excerpt && ( + + {excerpt.map(({ line: line_0, value }) => ( + + + + {String(line_0).padStart(lineWidth, ' ')}: + + + + + {' ' + value} + + + ))} + + )} + + {error.stack && ( + + {error.stack + .split('\n') + .slice(1) + .map(line_1 => { + const parsedLine = getStackUtils().parseLine(line_1) + + // If the line from the stack cannot be parsed, we print out the unparsed line. + if (!parsedLine) { + return ( + + - + {line_1} + + ) + } + + return ( + + - + {parsedLine.function} + + {' '} + ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column}) + + + ) + })} + + )} + + ) +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjb2RlRXhjZXJwdCIsIkNvZGVFeGNlcnB0IiwicmVhZEZpbGVTeW5jIiwiUmVhY3QiLCJTdGFja1V0aWxzIiwiQm94IiwiVGV4dCIsImNsZWFudXBQYXRoIiwicGF0aCIsInJlcGxhY2UiLCJwcm9jZXNzIiwiY3dkIiwic3RhY2tVdGlscyIsImdldFN0YWNrVXRpbHMiLCJpbnRlcm5hbHMiLCJub2RlSW50ZXJuYWxzIiwiUHJvcHMiLCJlcnJvciIsIkVycm9yIiwiRXJyb3JPdmVydmlldyIsInN0YWNrIiwic3BsaXQiLCJzbGljZSIsInVuZGVmaW5lZCIsIm9yaWdpbiIsInBhcnNlTGluZSIsImZpbGVQYXRoIiwiZmlsZSIsImV4Y2VycHQiLCJsaW5lV2lkdGgiLCJsaW5lIiwic291cmNlQ29kZSIsIk1hdGgiLCJtYXgiLCJTdHJpbmciLCJsZW5ndGgiLCJtZXNzYWdlIiwiY29sdW1uIiwibWFwIiwidmFsdWUiLCJwYWRTdGFydCIsInBhcnNlZExpbmUiLCJmdW5jdGlvbiJdLCJzb3VyY2VzIjpbIkVycm9yT3ZlcnZpZXcudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCBjb2RlRXhjZXJwdCwgeyB0eXBlIENvZGVFeGNlcnB0IH0gZnJvbSAnY29kZS1leGNlcnB0J1xuaW1wb3J0IHsgcmVhZEZpbGVTeW5jIH0gZnJvbSAnZnMnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgU3RhY2tVdGlscyBmcm9tICdzdGFjay11dGlscydcbmltcG9ydCBCb3ggZnJvbSAnLi9Cb3guanMnXG5pbXBvcnQgVGV4dCBmcm9tICcuL1RleHQuanMnXG5cbi8qIGVzbGludC1kaXNhYmxlIGN1c3RvbS1ydWxlcy9uby1wcm9jZXNzLWN3ZCAtLSBzdGFjayB0cmFjZSBmaWxlOi8vIHBhdGhzIGFyZSByZWxhdGl2ZSB0byB0aGUgcmVhbCBPUyBjd2QsIG5vdCB0aGUgdmlydHVhbCBjd2QgKi9cblxuLy8gRXJyb3IncyBzb3VyY2UgZmlsZSBpcyByZXBvcnRlZCBhcyBmaWxlOi8vL2hvbWUvdXNlci9maWxlLmpzXG4vLyBUaGlzIGZ1bmN0aW9uIHJlbW92ZXMgdGhlIGZpbGU6Ly9bY3dkXSBwYXJ0XG5jb25zdCBjbGVhbnVwUGF0aCA9IChwYXRoOiBzdHJpbmcgfCB1bmRlZmluZWQpOiBzdHJpbmcgfCB1bmRlZmluZWQgPT4ge1xuICByZXR1cm4gcGF0aD8ucmVwbGFjZShgZmlsZTovLyR7cHJvY2Vzcy5jd2QoKX0vYCwgJycpXG59XG5cbmxldCBzdGFja1V0aWxzOiBTdGFja1V0aWxzIHwgdW5kZWZpbmVkXG5mdW5jdGlvbiBnZXRTdGFja1V0aWxzKCk6IFN0YWNrVXRpbHMge1xuICByZXR1cm4gKHN0YWNrVXRpbHMgPz89IG5ldyBTdGFja1V0aWxzKHtcbiAgICBjd2Q6IHByb2Nlc3MuY3dkKCksXG4gICAgaW50ZXJuYWxzOiBTdGFja1V0aWxzLm5vZGVJbnRlcm5hbHMoKSxcbiAgfSkpXG59XG5cbi8qIGVzbGludC1lbmFibGUgY3VzdG9tLXJ1bGVzL25vLXByb2Nlc3MtY3dkICovXG5cbnR5cGUgUHJvcHMgPSB7XG4gIHJlYWRvbmx5IGVycm9yOiBFcnJvclxufVxuXG5leHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBFcnJvck92ZXJ2aWV3KHsgZXJyb3IgfTogUHJvcHMpIHtcbiAgY29uc3Qgc3RhY2sgPSBlcnJvci5zdGFjayA/IGVycm9yLnN0YWNrLnNwbGl0KCdcXG4nKS5zbGljZSgxKSA6IHVuZGVmaW5lZFxuICBjb25zdCBvcmlnaW4gPSBzdGFjayA/IGdldFN0YWNrVXRpbHMoKS5wYXJzZUxpbmUoc3RhY2tbMF0hKSA6IHVuZGVmaW5lZFxuICBjb25zdCBmaWxlUGF0aCA9IGNsZWFudXBQYXRoKG9yaWdpbj8uZmlsZSlcbiAgbGV0IGV4Y2VycHQ6IENvZGVFeGNlcnB0W10gfCB1bmRlZmluZWRcbiAgbGV0IGxpbmVXaWR0aCA9IDBcblxuICBpZiAoZmlsZVBhdGggJiYgb3JpZ2luPy5saW5lKSB7XG4gICAgdHJ5IHtcbiAgICAgIC8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tc3luYy1mcyAtLSBzeW5jIHJlbmRlciBwYXRoOyBlcnJvciBvdmVybGF5IGNhbid0IGdvIGFzeW5jIHdpdGhvdXQgc3VzcGVuc2UgcmVzdHJ1Y3R1cmluZ1xuICAgICAgY29uc3Qgc291cmNlQ29kZSA9IHJlYWRGaWxlU3luYyhmaWxlUGF0aCwgJ3V0ZjgnKVxuICAgICAgZXhjZXJwdCA9IGNvZGVFeGNlcnB0KHNvdXJjZUNvZGUsIG9yaWdpbi5saW5lKVxuXG4gICAgICBpZiAoZXhjZXJwdCkge1xuICAgICAgICBmb3IgKGNvbnN0IHsgbGluZSB9IG9mIGV4Y2VycHQpIHtcbiAgICAgICAgICBsaW5lV2lkdGggPSBNYXRoLm1heChsaW5lV2lkdGgsIFN0cmluZyhsaW5lKS5sZW5ndGgpXG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9IGNhdGNoIHtcbiAgICAgIC8vIGZpbGUgbm90IHJlYWRhYmxlIOKAlCBza2lwIHNvdXJjZSBjb250ZXh0XG4gICAgfVxuICB9XG5cbiAgcmV0dXJuIChcbiAgICA8Qm94IGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIiBwYWRkaW5nPXsxfT5cbiAgICAgIDxCb3g+XG4gICAgICAgIDxUZXh0IGJhY2tncm91bmRDb2xvcj1cImFuc2k6cmVkXCIgY29sb3I9XCJhbnNpOndoaXRlXCI+XG4gICAgICAgICAgeycgJ31cbiAgICAgICAgICBFUlJPUnsnICd9XG4gICAgICAgIDwvVGV4dD5cblxuICAgICAgICA8VGV4dD4ge2Vycm9yLm1lc3NhZ2V9PC9UZXh0PlxuICAgICAgPC9Cb3g+XG5cbiAgICAgIHtvcmlnaW4gJiYgZmlsZVBhdGggJiYgKFxuICAgICAgICA8Qm94IG1hcmdpblRvcD17MX0+XG4gICAgICAgICAgPFRleHQgZGltPlxuICAgICAgICAgICAge2ZpbGVQYXRofTp7b3JpZ2luLmxpbmV9OntvcmlnaW4uY29sdW1ufVxuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgPC9Cb3g+XG4gICAgICApfVxuXG4gICAgICB7b3JpZ2luICYmIGV4Y2VycHQgJiYgKFxuICAgICAgICA8Qm94IG1hcmdpblRvcD17MX0gZmxleERpcmVjdGlvbj1cImNvbHVtblwiPlxuICAgICAgICAgIHtleGNlcnB0Lm1hcCgoeyBsaW5lLCB2YWx1ZSB9KSA9PiAoXG4gICAgICAgICAgICA8Qm94IGtleT17bGluZX0+XG4gICAgICAgICAgICAgIDxCb3ggd2lkdGg9e2xpbmVXaWR0aCArIDF9PlxuICAgICAgICAgICAgICAgIDxUZXh0XG4gICAgICAgICAgICAgICAgICBkaW09e2xpbmUgIT09IG9yaWdpbi5saW5lfVxuICAgICAgICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yPXtcbiAgICAgICAgICAgICAgICAgICAgbGluZSA9PT0gb3JpZ2luLmxpbmUgPyAnYW5zaTpyZWQnIDogdW5kZWZpbmVkXG4gICAgICAgICAgICAgICAgICB9XG4gICAgICAgICAgICAgICAgICBjb2xvcj17bGluZSA9PT0gb3JpZ2luLmxpbmUgPyAnYW5zaTp3aGl0ZScgOiB1bmRlZmluZWR9XG4gICAgICAgICAgICAgICAgPlxuICAgICAgICAgICAgICAgICAge1N0cmluZyhsaW5lKS5wYWRTdGFydChsaW5lV2lkdGgsICcgJyl9OlxuICAgICAgICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgICAgICAgPC9Cb3g+XG5cbiAgICAgICAgICAgICAgPFRleHRcbiAgICAgICAgICAgICAgICBrZXk9e2xpbmV9XG4gICAgICAgICAgICAgICAgYmFja2dyb3VuZENvbG9yPXtsaW5lID09PSBvcmlnaW4ubGluZSA/ICdhbnNpOnJlZCcgOiB1bmRlZmluZWR9XG4gICAgICAgICAgICAgICAgY29sb3I9e2xpbmUgPT09IG9yaWdpbi5saW5lID8gJ2Fuc2k6d2hpdGUnIDogdW5kZWZpbmVkfVxuICAgICAgICAgICAgICA+XG4gICAgICAgICAgICAgICAgeycgJyArIHZhbHVlfVxuICAgICAgICAgICAgICA8L1RleHQ+XG4gICAgICAgICAgICA8L0JveD5cbiAgICAgICAgICApKX1cbiAgICAgICAgPC9Cb3g+XG4gICAgICApfVxuXG4gICAgICB7ZXJyb3Iuc3RhY2sgJiYgKFxuICAgICAgICA8Qm94IG1hcmdpblRvcD17MX0gZmxleERpcmVjdGlvbj1cImNvbHVtblwiPlxuICAgICAgICAgIHtlcnJvci5zdGFja1xuICAgICAgICAgICAgLnNwbGl0KCdcXG4nKVxuICAgICAgICAgICAgLnNsaWNlKDEpXG4gICAgICAgICAgICAubWFwKGxpbmUgPT4ge1xuICAgICAgICAgICAgICBjb25zdCBwYXJzZWRMaW5lID0gZ2V0U3RhY2tVdGlscygpLnBhcnNlTGluZShsaW5lKVxuXG4gICAgICAgICAgICAgIC8vIElmIHRoZSBsaW5lIGZyb20gdGhlIHN0YWNrIGNhbm5vdCBiZSBwYXJzZWQsIHdlIHByaW50IG91dCB0aGUgdW5wYXJzZWQgbGluZS5cbiAgICAgICAgICAgICAgaWYgKCFwYXJzZWRMaW5lKSB7XG4gICAgICAgICAgICAgICAgcmV0dXJuIChcbiAgICAgICAgICAgICAgICAgIDxCb3gga2V5PXtsaW5lfT5cbiAgICAgICAgICAgICAgICAgICAgPFRleHQgZGltPi0gPC9UZXh0PlxuICAgICAgICAgICAgICAgICAgICA8VGV4dCBib2xkPntsaW5lfTwvVGV4dD5cbiAgICAgICAgICAgICAgICAgIDwvQm94PlxuICAgICAgICAgICAgICAgIClcbiAgICAgICAgICAgICAgfVxuXG4gICAgICAgICAgICAgIHJldHVybiAoXG4gICAgICAgICAgICAgICAgPEJveCBrZXk9e2xpbmV9PlxuICAgICAgICAgICAgICAgICAgPFRleHQgZGltPi0gPC9UZXh0PlxuICAgICAgICAgICAgICAgICAgPFRleHQgYm9sZD57cGFyc2VkTGluZS5mdW5jdGlvbn08L1RleHQ+XG4gICAgICAgICAgICAgICAgICA8VGV4dCBkaW0+XG4gICAgICAgICAgICAgICAgICAgIHsnICd9XG4gICAgICAgICAgICAgICAgICAgICh7Y2xlYW51cFBhdGgocGFyc2VkTGluZS5maWxlKSA/PyAnJ306e3BhcnNlZExpbmUubGluZX06XG4gICAgICAgICAgICAgICAgICAgIHtwYXJzZWRMaW5lLmNvbHVtbn0pXG4gICAgICAgICAgICAgICAgICA8L1RleHQ+XG4gICAgICAgICAgICAgICAgPC9Cb3g+XG4gICAgICAgICAgICAgIClcbiAgICAgICAgICAgIH0pfVxuICAgICAgICA8L0JveD5cbiAgICAgICl9XG4gICAgPC9Cb3g+XG4gIClcbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBT0EsV0FBVyxJQUFJLEtBQUtDLFdBQVcsUUFBUSxjQUFjO0FBQzVELFNBQVNDLFlBQVksUUFBUSxJQUFJO0FBQ2pDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLE9BQU9DLFVBQVUsTUFBTSxhQUFhO0FBQ3BDLE9BQU9DLEdBQUcsTUFBTSxVQUFVO0FBQzFCLE9BQU9DLElBQUksTUFBTSxXQUFXOztBQUU1Qjs7QUFFQTtBQUNBO0FBQ0EsTUFBTUMsV0FBVyxHQUFHQSxDQUFDQyxJQUFJLEVBQUUsTUFBTSxHQUFHLFNBQVMsQ0FBQyxFQUFFLE1BQU0sR0FBRyxTQUFTLElBQUk7RUFDcEUsT0FBT0EsSUFBSSxFQUFFQyxPQUFPLENBQUMsVUFBVUMsT0FBTyxDQUFDQyxHQUFHLENBQUMsQ0FBQyxHQUFHLEVBQUUsRUFBRSxDQUFDO0FBQ3RELENBQUM7QUFFRCxJQUFJQyxVQUFVLEVBQUVSLFVBQVUsR0FBRyxTQUFTO0FBQ3RDLFNBQVNTLGFBQWFBLENBQUEsQ0FBRSxFQUFFVCxVQUFVLENBQUM7RUFDbkMsT0FBUVEsVUFBVSxLQUFLLElBQUlSLFVBQVUsQ0FBQztJQUNwQ08sR0FBRyxFQUFFRCxPQUFPLENBQUNDLEdBQUcsQ0FBQyxDQUFDO0lBQ2xCRyxTQUFTLEVBQUVWLFVBQVUsQ0FBQ1csYUFBYSxDQUFDO0VBQ3RDLENBQUMsQ0FBQztBQUNKOztBQUVBOztBQUVBLEtBQUtDLEtBQUssR0FBRztFQUNYLFNBQVNDLEtBQUssRUFBRUMsS0FBSztBQUN2QixDQUFDO0FBRUQsZUFBZSxTQUFTQyxhQUFhQSxDQUFDO0VBQUVGO0FBQWEsQ0FBTixFQUFFRCxLQUFLLEVBQUU7RUFDdEQsTUFBTUksS0FBSyxHQUFHSCxLQUFLLENBQUNHLEtBQUssR0FBR0gsS0FBSyxDQUFDRyxLQUFLLENBQUNDLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FBQ0MsS0FBSyxDQUFDLENBQUMsQ0FBQyxHQUFHQyxTQUFTO0VBQ3hFLE1BQU1DLE1BQU0sR0FBR0osS0FBSyxHQUFHUCxhQUFhLENBQUMsQ0FBQyxDQUFDWSxTQUFTLENBQUNMLEtBQUssQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEdBQUdHLFNBQVM7RUFDdkUsTUFBTUcsUUFBUSxHQUFHbkIsV0FBVyxDQUFDaUIsTUFBTSxFQUFFRyxJQUFJLENBQUM7RUFDMUMsSUFBSUMsT0FBTyxFQUFFM0IsV0FBVyxFQUFFLEdBQUcsU0FBUztFQUN0QyxJQUFJNEIsU0FBUyxHQUFHLENBQUM7RUFFakIsSUFBSUgsUUFBUSxJQUFJRixNQUFNLEVBQUVNLElBQUksRUFBRTtJQUM1QixJQUFJO01BQ0Y7TUFDQSxNQUFNQyxVQUFVLEdBQUc3QixZQUFZLENBQUN3QixRQUFRLEVBQUUsTUFBTSxDQUFDO01BQ2pERSxPQUFPLEdBQUc1QixXQUFXLENBQUMrQixVQUFVLEVBQUVQLE1BQU0sQ0FBQ00sSUFBSSxDQUFDO01BRTlDLElBQUlGLE9BQU8sRUFBRTtRQUNYLEtBQUssTUFBTTtVQUFFRTtRQUFLLENBQUMsSUFBSUYsT0FBTyxFQUFFO1VBQzlCQyxTQUFTLEdBQUdHLElBQUksQ0FBQ0MsR0FBRyxDQUFDSixTQUFTLEVBQUVLLE1BQU0sQ0FBQ0osSUFBSSxDQUFDLENBQUNLLE1BQU0sQ0FBQztRQUN0RDtNQUNGO0lBQ0YsQ0FBQyxDQUFDLE1BQU07TUFDTjtJQUFBO0VBRUo7RUFFQSxPQUNFLENBQUMsR0FBRyxDQUFDLGFBQWEsQ0FBQyxRQUFRLENBQUMsT0FBTyxDQUFDLENBQUMsQ0FBQyxDQUFDO0FBQzNDLE1BQU0sQ0FBQyxHQUFHO0FBQ1YsUUFBUSxDQUFDLElBQUksQ0FBQyxlQUFlLENBQUMsVUFBVSxDQUFDLEtBQUssQ0FBQyxZQUFZO0FBQzNELFVBQVUsQ0FBQyxHQUFHO0FBQ2QsZUFBZSxDQUFDLEdBQUc7QUFDbkIsUUFBUSxFQUFFLElBQUk7QUFDZDtBQUNBLFFBQVEsQ0FBQyxJQUFJLENBQUMsQ0FBQyxDQUFDbEIsS0FBSyxDQUFDbUIsT0FBTyxDQUFDLEVBQUUsSUFBSTtBQUNwQyxNQUFNLEVBQUUsR0FBRztBQUNYO0FBQ0EsTUFBTSxDQUFDWixNQUFNLElBQUlFLFFBQVEsSUFDakIsQ0FBQyxHQUFHLENBQUMsU0FBUyxDQUFDLENBQUMsQ0FBQyxDQUFDO0FBQzFCLFVBQVUsQ0FBQyxJQUFJLENBQUMsR0FBRztBQUNuQixZQUFZLENBQUNBLFFBQVEsQ0FBQyxDQUFDLENBQUNGLE1BQU0sQ0FBQ00sSUFBSSxDQUFDLENBQUMsQ0FBQ04sTUFBTSxDQUFDYSxNQUFNO0FBQ25ELFVBQVUsRUFBRSxJQUFJO0FBQ2hCLFFBQVEsRUFBRSxHQUFHLENBQ047QUFDUDtBQUNBLE1BQU0sQ0FBQ2IsTUFBTSxJQUFJSSxPQUFPLElBQ2hCLENBQUMsR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLGFBQWEsQ0FBQyxRQUFRO0FBQ2pELFVBQVUsQ0FBQ0EsT0FBTyxDQUFDVSxHQUFHLENBQUMsQ0FBQztRQUFFUixJQUFJLEVBQUpBLE1BQUk7UUFBRVM7TUFBTSxDQUFDLEtBQzNCLENBQUMsR0FBRyxDQUFDLEdBQUcsQ0FBQyxDQUFDVCxNQUFJLENBQUM7QUFDM0IsY0FBYyxDQUFDLEdBQUcsQ0FBQyxLQUFLLENBQUMsQ0FBQ0QsU0FBUyxHQUFHLENBQUMsQ0FBQztBQUN4QyxnQkFBZ0IsQ0FBQyxJQUFJLENBQ0gsR0FBRyxDQUFDLENBQUNDLE1BQUksS0FBS04sTUFBTSxDQUFDTSxJQUFJLENBQUMsQ0FDMUIsZUFBZSxDQUFDLENBQ2RBLE1BQUksS0FBS04sTUFBTSxDQUFDTSxJQUFJLEdBQUcsVUFBVSxHQUFHUCxTQUN0QyxDQUFDLENBQ0QsS0FBSyxDQUFDLENBQUNPLE1BQUksS0FBS04sTUFBTSxDQUFDTSxJQUFJLEdBQUcsWUFBWSxHQUFHUCxTQUFTLENBQUM7QUFFekUsa0JBQWtCLENBQUNXLE1BQU0sQ0FBQ0osTUFBSSxDQUFDLENBQUNVLFFBQVEsQ0FBQ1gsU0FBUyxFQUFFLEdBQUcsQ0FBQyxDQUFDO0FBQ3pELGdCQUFnQixFQUFFLElBQUk7QUFDdEIsY0FBYyxFQUFFLEdBQUc7QUFDbkI7QUFDQSxjQUFjLENBQUMsSUFBSSxDQUNILEdBQUcsQ0FBQyxDQUFDQyxNQUFJLENBQUMsQ0FDVixlQUFlLENBQUMsQ0FBQ0EsTUFBSSxLQUFLTixNQUFNLENBQUNNLElBQUksR0FBRyxVQUFVLEdBQUdQLFNBQVMsQ0FBQyxDQUMvRCxLQUFLLENBQUMsQ0FBQ08sTUFBSSxLQUFLTixNQUFNLENBQUNNLElBQUksR0FBRyxZQUFZLEdBQUdQLFNBQVMsQ0FBQztBQUV2RSxnQkFBZ0IsQ0FBQyxHQUFHLEdBQUdnQixLQUFLO0FBQzVCLGNBQWMsRUFBRSxJQUFJO0FBQ3BCLFlBQVksRUFBRSxHQUFHLENBQ04sQ0FBQztBQUNaLFFBQVEsRUFBRSxHQUFHLENBQ047QUFDUDtBQUNBLE1BQU0sQ0FBQ3RCLEtBQUssQ0FBQ0csS0FBSyxJQUNWLENBQUMsR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLGFBQWEsQ0FBQyxRQUFRO0FBQ2pELFVBQVUsQ0FBQ0gsS0FBSyxDQUFDRyxLQUFLLENBQ1RDLEtBQUssQ0FBQyxJQUFJLENBQUMsQ0FDWEMsS0FBSyxDQUFDLENBQUMsQ0FBQyxDQUNSZ0IsR0FBRyxDQUFDUixNQUFJLElBQUk7UUFDWCxNQUFNVyxVQUFVLEdBQUc1QixhQUFhLENBQUMsQ0FBQyxDQUFDWSxTQUFTLENBQUNLLE1BQUksQ0FBQzs7UUFFbEQ7UUFDQSxJQUFJLENBQUNXLFVBQVUsRUFBRTtVQUNmLE9BQ0UsQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDLENBQUNYLE1BQUksQ0FBQztBQUNqQyxvQkFBb0IsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEVBQUUsRUFBRSxJQUFJO0FBQ3RDLG9CQUFvQixDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQ0EsTUFBSSxDQUFDLEVBQUUsSUFBSTtBQUMzQyxrQkFBa0IsRUFBRSxHQUFHLENBQUM7UUFFVjtRQUVBLE9BQ0UsQ0FBQyxHQUFHLENBQUMsR0FBRyxDQUFDLENBQUNBLE1BQUksQ0FBQztBQUMvQixrQkFBa0IsQ0FBQyxJQUFJLENBQUMsR0FBRyxDQUFDLEVBQUUsRUFBRSxJQUFJO0FBQ3BDLGtCQUFrQixDQUFDLElBQUksQ0FBQyxJQUFJLENBQUMsQ0FBQ1csVUFBVSxDQUFDQyxRQUFRLENBQUMsRUFBRSxJQUFJO0FBQ3hELGtCQUFrQixDQUFDLElBQUksQ0FBQyxHQUFHO0FBQzNCLG9CQUFvQixDQUFDLEdBQUc7QUFDeEIscUJBQXFCLENBQUNuQyxXQUFXLENBQUNrQyxVQUFVLENBQUNkLElBQUksQ0FBQyxJQUFJLEVBQUUsQ0FBQyxDQUFDLENBQUNjLFVBQVUsQ0FBQ1gsSUFBSSxDQUFDO0FBQzNFLG9CQUFvQixDQUFDVyxVQUFVLENBQUNKLE1BQU0sQ0FBQztBQUN2QyxrQkFBa0IsRUFBRSxJQUFJO0FBQ3hCLGdCQUFnQixFQUFFLEdBQUcsQ0FBQztNQUVWLENBQUMsQ0FBQztBQUNkLFFBQVEsRUFBRSxHQUFHLENBQ047QUFDUCxJQUFJLEVBQUUsR0FBRyxDQUFDO0FBRVYiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx new file mode 100644 index 0000000000..72c94fa11f --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx @@ -0,0 +1,53 @@ +import type { ReactNode } from 'react' +import React from 'react' +import { c as _c } from 'react/compiler-runtime' + +import { supportsHyperlinks } from '../supports-hyperlinks.js' + +import Text from './Text.js' +export type Props = { + readonly children?: ReactNode + readonly url: string + readonly fallback?: ReactNode +} + +export default function Link(t0) { + const $ = _c(5) + + const { children, url, fallback } = t0 + + const content = children ?? url + + if (supportsHyperlinks()) { + let t1 + + if ($[0] !== content || $[1] !== url) { + t1 = ( + + {content} + + ) + $[0] = content + $[1] = url + $[2] = t1 + } else { + t1 = $[2] + } + + return t1 + } + + const t1 = fallback ?? content + let t2 + + if ($[3] !== t1) { + t2 = {t1} + $[3] = t1 + $[4] = t2 + } else { + t2 = $[4] + } + + return t2 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsInN1cHBvcnRzSHlwZXJsaW5rcyIsIlRleHQiLCJQcm9wcyIsImNoaWxkcmVuIiwidXJsIiwiZmFsbGJhY2siLCJMaW5rIiwidDAiLCIkIiwiX2MiLCJjb250ZW50IiwidDEiLCJ0MiJdLCJzb3VyY2VzIjpbIkxpbmsudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB0eXBlIHsgUmVhY3ROb2RlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBzdXBwb3J0c0h5cGVybGlua3MgfSBmcm9tICcuLi9zdXBwb3J0cy1oeXBlcmxpbmtzLmpzJ1xuaW1wb3J0IFRleHQgZnJvbSAnLi9UZXh0LmpzJ1xuXG5leHBvcnQgdHlwZSBQcm9wcyA9IHtcbiAgcmVhZG9ubHkgY2hpbGRyZW4/OiBSZWFjdE5vZGVcbiAgcmVhZG9ubHkgdXJsOiBzdHJpbmdcbiAgcmVhZG9ubHkgZmFsbGJhY2s/OiBSZWFjdE5vZGVcbn1cblxuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTGluayh7XG4gIGNoaWxkcmVuLFxuICB1cmwsXG4gIGZhbGxiYWNrLFxufTogUHJvcHMpOiBSZWFjdC5SZWFjdE5vZGUge1xuICAvLyBVc2UgY2hpbGRyZW4gaWYgcHJvdmlkZWQsIG90aGVyd2lzZSBkaXNwbGF5IHRoZSBVUkxcbiAgY29uc3QgY29udGVudCA9IGNoaWxkcmVuID8/IHVybFxuXG4gIGlmIChzdXBwb3J0c0h5cGVybGlua3MoKSkge1xuICAgIC8vIFdyYXAgaW4gVGV4dCB0byBlbnN1cmUgd2UncmUgaW4gYSB0ZXh0IGNvbnRleHRcbiAgICAvLyAoaW5rLWxpbmsgaXMgYSB0ZXh0IGVsZW1lbnQgbGlrZSBpbmstdGV4dClcbiAgICByZXR1cm4gKFxuICAgICAgPFRleHQ+XG4gICAgICAgIDxpbmstbGluayBocmVmPXt1cmx9Pntjb250ZW50fTwvaW5rLWxpbms+XG4gICAgICA8L1RleHQ+XG4gICAgKVxuICB9XG5cbiAgcmV0dXJuIDxUZXh0PntmYWxsYmFjayA/PyBjb250ZW50fTwvVGV4dD5cbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLGNBQWNBLFNBQVMsUUFBUSxPQUFPO0FBQ3RDLE9BQU9DLEtBQUssTUFBTSxPQUFPO0FBQ3pCLFNBQVNDLGtCQUFrQixRQUFRLDJCQUEyQjtBQUM5RCxPQUFPQyxJQUFJLE1BQU0sV0FBVztBQUU1QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQixTQUFTQyxRQUFRLENBQUMsRUFBRUwsU0FBUztFQUM3QixTQUFTTSxHQUFHLEVBQUUsTUFBTTtFQUNwQixTQUFTQyxRQUFRLENBQUMsRUFBRVAsU0FBUztBQUMvQixDQUFDO0FBRUQsZUFBZSxTQUFBUSxLQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQWM7SUFBQU4sUUFBQTtJQUFBQyxHQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFJckI7RUFFTixNQUFBRyxPQUFBLEdBQWdCUCxRQUFlLElBQWZDLEdBQWU7RUFFL0IsSUFBSUosa0JBQWtCLENBQUMsQ0FBQztJQUFBLElBQUFXLEVBQUE7SUFBQSxJQUFBSCxDQUFBLFFBQUFFLE9BQUEsSUFBQUYsQ0FBQSxRQUFBSixHQUFBO01BSXBCTyxFQUFBLElBQUMsSUFBSSxDQUNILFNBQXlDLENBQXpCUCxJQUFHLENBQUhBLElBQUUsQ0FBQyxDQUFHTSxRQUFNLENBQUUsRUFBOUIsUUFBeUMsQ0FDM0MsRUFGQyxJQUFJLENBRUU7TUFBQUYsQ0FBQSxNQUFBRSxPQUFBO01BQUFGLENBQUEsTUFBQUosR0FBQTtNQUFBSSxDQUFBLE1BQUFHLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFILENBQUE7SUFBQTtJQUFBLE9BRlBHLEVBRU87RUFBQTtFQUlHLE1BQUFBLEVBQUEsR0FBQU4sUUFBbUIsSUFBbkJLLE9BQW1CO0VBQUEsSUFBQUUsRUFBQTtFQUFBLElBQUFKLENBQUEsUUFBQUcsRUFBQTtJQUExQkMsRUFBQSxJQUFDLElBQUksQ0FBRSxDQUFBRCxFQUFrQixDQUFFLEVBQTFCLElBQUksQ0FBNkI7SUFBQUgsQ0FBQSxNQUFBRyxFQUFBO0lBQUFILENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsT0FBbENJLEVBQWtDO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx new file mode 100644 index 0000000000..54dfa50fa6 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { c as _c } from 'react/compiler-runtime' +export type Props = { + /** + * Number of newlines to insert. + * + * @default 1 + */ + readonly count?: number +} + +/** + * Adds one or more newline (\n) characters. Must be used within components. + */ +export default function Newline(t0) { + const $ = _c(4) + + const { count: t1 } = t0 + + const count = t1 === undefined ? 1 : t1 + let t2 + + if ($[0] !== count) { + t2 = '\n'.repeat(count) + $[0] = count + $[1] = t2 + } else { + t2 = $[1] + } + + let t3 + + if ($[2] !== t2) { + t3 = {t2} + $[2] = t2 + $[3] = t3 + } else { + t3 = $[3] + } + + return t3 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwiY291bnQiLCJOZXdsaW5lIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwicmVwZWF0IiwidDMiXSwic291cmNlcyI6WyJOZXdsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5cbmV4cG9ydCB0eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogTnVtYmVyIG9mIG5ld2xpbmVzIHRvIGluc2VydC5cbiAgICpcbiAgICogQGRlZmF1bHQgMVxuICAgKi9cbiAgcmVhZG9ubHkgY291bnQ/OiBudW1iZXJcbn1cblxuLyoqXG4gKiBBZGRzIG9uZSBvciBtb3JlIG5ld2xpbmUgKFxcbikgY2hhcmFjdGVycy4gTXVzdCBiZSB1c2VkIHdpdGhpbiA8VGV4dD4gY29tcG9uZW50cy5cbiAqL1xuZXhwb3J0IGRlZmF1bHQgZnVuY3Rpb24gTmV3bGluZSh7IGNvdW50ID0gMSB9OiBQcm9wcykge1xuICByZXR1cm4gPGluay10ZXh0PnsnXFxuJy5yZXBlYXQoY291bnQpfTwvaW5rLXRleHQ+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixPQUFPLEtBQUtDLEtBQUssR0FBRztFQUNsQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUUsTUFBTTtBQUN6QixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBLGVBQWUsU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBSixLQUFBLEVBQUFLO0VBQUEsSUFBQUgsRUFBb0I7RUFBbEIsTUFBQUYsS0FBQSxHQUFBSyxFQUFTLEtBQVRDLFNBQVMsR0FBVCxDQUFTLEdBQVRELEVBQVM7RUFBQSxJQUFBRSxFQUFBO0VBQUEsSUFBQUosQ0FBQSxRQUFBSCxLQUFBO0lBQ3ZCTyxFQUFBLE9BQUksQ0FBQUMsTUFBTyxDQUFDUixLQUFLLENBQUM7SUFBQUcsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUksRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUosQ0FBQTtFQUFBO0VBQUEsSUFBQU0sRUFBQTtFQUFBLElBQUFOLENBQUEsUUFBQUksRUFBQTtJQUE3QkUsRUFBQSxZQUF5QyxDQUE5QixDQUFBRixFQUFpQixDQUFFLEVBQTlCLFFBQXlDO0lBQUFKLENBQUEsTUFBQUksRUFBQTtJQUFBSixDQUFBLE1BQUFNLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFOLENBQUE7RUFBQTtFQUFBLE9BQXpDTSxFQUF5QztBQUFBIiwiaWdub3JlTGlzdCI6W119 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx b/ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx new file mode 100644 index 0000000000..e3da698520 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { c as _c } from 'react/compiler-runtime' + +import Box, { type Props as BoxProps } from './Box.js' +type Props = Omit & { + /** + * Extend the exclusion zone from column 0 to this box's right edge, + * for every row this box occupies. Use for gutters rendered inside a + * wider indented container (e.g. a diff inside a tool message row): + * without this, a multi-row drag picks up the container's leading + * indent on rows below the prefix. + * + * @default false + */ + fromLeftEdge?: boolean +} + +/** + * Marks its contents as non-selectable in fullscreen text selection. + * Cells inside this box are skipped by both the selection highlight and + * the copied text — the gutter stays visually unchanged while the user + * drags, making it clear what will be copied. + * + * Use to fence off gutters (line numbers, diff +/- sigils, list bullets) + * so click-drag over rendered code yields clean pasteable content: + * + * + * 42 + + * const x = 1 + * + * + * Only affects alt-screen text selection ( with mouse + * tracking). No-op in the main-screen scrollback render where the + * terminal's native selection is used instead. + */ +export function NoSelect(t0) { + const $ = _c(8) + let boxProps + let children + let fromLeftEdge + + if ($[0] !== t0) { + ;({ children, fromLeftEdge, ...boxProps } = t0) + $[0] = t0 + $[1] = boxProps + $[2] = children + $[3] = fromLeftEdge + } else { + boxProps = $[1] + children = $[2] + fromLeftEdge = $[3] + } + + const t1 = fromLeftEdge ? 'from-left-edge' : true + let t2 + + if ($[4] !== boxProps || $[5] !== children || $[6] !== t1) { + t2 = ( + + {children} + + ) + $[4] = boxProps + $[5] = children + $[6] = t1 + $[7] = t2 + } else { + t2 = $[7] + } + + return t2 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiQm94IiwiUHJvcHMiLCJCb3hQcm9wcyIsIk9taXQiLCJmcm9tTGVmdEVkZ2UiLCJOb1NlbGVjdCIsInQwIiwiJCIsIl9jIiwiYm94UHJvcHMiLCJjaGlsZHJlbiIsInQxIiwidDIiXSwic291cmNlcyI6WyJOb1NlbGVjdC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IHR5cGUgUHJvcHNXaXRoQ2hpbGRyZW4gfSBmcm9tICdyZWFjdCdcbmltcG9ydCBCb3gsIHsgdHlwZSBQcm9wcyBhcyBCb3hQcm9wcyB9IGZyb20gJy4vQm94LmpzJ1xuXG50eXBlIFByb3BzID0gT21pdDxCb3hQcm9wcywgJ25vU2VsZWN0Jz4gJiB7XG4gIC8qKlxuICAgKiBFeHRlbmQgdGhlIGV4Y2x1c2lvbiB6b25lIGZyb20gY29sdW1uIDAgdG8gdGhpcyBib3gncyByaWdodCBlZGdlLFxuICAgKiBmb3IgZXZlcnkgcm93IHRoaXMgYm94IG9jY3VwaWVzLiBVc2UgZm9yIGd1dHRlcnMgcmVuZGVyZWQgaW5zaWRlIGFcbiAgICogd2lkZXIgaW5kZW50ZWQgY29udGFpbmVyIChlLmcuIGEgZGlmZiBpbnNpZGUgYSB0b29sIG1lc3NhZ2Ugcm93KTpcbiAgICogd2l0aG91dCB0aGlzLCBhIG11bHRpLXJvdyBkcmFnIHBpY2tzIHVwIHRoZSBjb250YWluZXIncyBsZWFkaW5nXG4gICAqIGluZGVudCBvbiByb3dzIGJlbG93IHRoZSBwcmVmaXguXG4gICAqXG4gICAqIEBkZWZhdWx0IGZhbHNlXG4gICAqL1xuICBmcm9tTGVmdEVkZ2U/OiBib29sZWFuXG59XG5cbi8qKlxuICogTWFya3MgaXRzIGNvbnRlbnRzIGFzIG5vbi1zZWxlY3RhYmxlIGluIGZ1bGxzY3JlZW4gdGV4dCBzZWxlY3Rpb24uXG4gKiBDZWxscyBpbnNpZGUgdGhpcyBib3ggYXJlIHNraXBwZWQgYnkgYm90aCB0aGUgc2VsZWN0aW9uIGhpZ2hsaWdodCBhbmRcbiAqIHRoZSBjb3BpZWQgdGV4dCDigJQgdGhlIGd1dHRlciBzdGF5cyB2aXN1YWxseSB1bmNoYW5nZWQgd2hpbGUgdGhlIHVzZXJcbiAqIGRyYWdzLCBtYWtpbmcgaXQgY2xlYXIgd2hhdCB3aWxsIGJlIGNvcGllZC5cbiAqXG4gKiBVc2UgdG8gZmVuY2Ugb2ZmIGd1dHRlcnMgKGxpbmUgbnVtYmVycywgZGlmZiArLy0gc2lnaWxzLCBsaXN0IGJ1bGxldHMpXG4gKiBzbyBjbGljay1kcmFnIG92ZXIgcmVuZGVyZWQgY29kZSB5aWVsZHMgY2xlYW4gcGFzdGVhYmxlIGNvbnRlbnQ6XG4gKlxuICogICA8Qm94IGZsZXhEaXJlY3Rpb249XCJyb3dcIj5cbiAqICAgICA8Tm9TZWxlY3QgZnJvbUxlZnRFZGdlPjxUZXh0IGRpbUNvbG9yPiA0MiArPC9UZXh0PjwvTm9TZWxlY3Q+XG4gKiAgICAgPFRleHQ+Y29uc3QgeCA9IDE8L1RleHQ+XG4gKiAgIDwvQm94PlxuICpcbiAqIE9ubHkgYWZmZWN0cyBhbHQtc2NyZWVuIHRleHQgc2VsZWN0aW9uICg8QWx0ZXJuYXRlU2NyZWVuPiB3aXRoIG1vdXNlXG4gKiB0cmFja2luZykuIE5vLW9wIGluIHRoZSBtYWluLXNjcmVlbiBzY3JvbGxiYWNrIHJlbmRlciB3aGVyZSB0aGVcbiAqIHRlcm1pbmFsJ3MgbmF0aXZlIHNlbGVjdGlvbiBpcyB1c2VkIGluc3RlYWQuXG4gKi9cbmV4cG9ydCBmdW5jdGlvbiBOb1NlbGVjdCh7XG4gIGNoaWxkcmVuLFxuICBmcm9tTGVmdEVkZ2UsXG4gIC4uLmJveFByb3BzXG59OiBQcm9wc1dpdGhDaGlsZHJlbjxQcm9wcz4pOiBSZWFjdC5SZWFjdE5vZGUge1xuICByZXR1cm4gKFxuICAgIDxCb3ggey4uLmJveFByb3BzfSBub1NlbGVjdD17ZnJvbUxlZnRFZGdlID8gJ2Zyb20tbGVmdC1lZGdlJyA6IHRydWV9PlxuICAgICAge2NoaWxkcmVufVxuICAgIDwvQm94PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUksS0FBS0MsaUJBQWlCLFFBQVEsT0FBTztBQUNyRCxPQUFPQyxHQUFHLElBQUksS0FBS0MsS0FBSyxJQUFJQyxRQUFRLFFBQVEsVUFBVTtBQUV0RCxLQUFLRCxLQUFLLEdBQUdFLElBQUksQ0FBQ0QsUUFBUSxFQUFFLFVBQVUsQ0FBQyxHQUFHO0VBQ3hDO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFRSxZQUFZLENBQUMsRUFBRSxPQUFPO0FBQ3hCLENBQUM7O0FBRUQ7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxTQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQUEsSUFBQUMsUUFBQTtFQUFBLElBQUFDLFFBQUE7RUFBQSxJQUFBTixZQUFBO0VBQUEsSUFBQUcsQ0FBQSxRQUFBRCxFQUFBO0lBQWtCO01BQUFJLFFBQUE7TUFBQU4sWUFBQTtNQUFBLEdBQUFLO0lBQUEsSUFBQUgsRUFJRTtJQUFBQyxDQUFBLE1BQUFELEVBQUE7SUFBQUMsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsUUFBQTtJQUFBSCxDQUFBLE1BQUFILFlBQUE7RUFBQTtJQUFBSyxRQUFBLEdBQUFGLENBQUE7SUFBQUcsUUFBQSxHQUFBSCxDQUFBO0lBQUFILFlBQUEsR0FBQUcsQ0FBQTtFQUFBO0VBRU0sTUFBQUksRUFBQSxHQUFBUCxZQUFZLEdBQVosZ0JBQXNDLEdBQXRDLElBQXNDO0VBQUEsSUFBQVEsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFHLFFBQUEsSUFBQUgsQ0FBQSxRQUFBSSxFQUFBO0lBQW5FQyxFQUFBLElBQUMsR0FBRyxLQUFLSCxRQUFRLEVBQVksUUFBc0MsQ0FBdEMsQ0FBQUUsRUFBcUMsQ0FBQyxDQUNoRUQsU0FBTyxDQUNWLEVBRkMsR0FBRyxDQUVFO0lBQUFILENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFHLFFBQUE7SUFBQUgsQ0FBQSxNQUFBSSxFQUFBO0lBQUFKLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FGTkssRUFFTTtBQUFBIiwiaWdub3JlTGlzdCI6W119 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx b/ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx new file mode 100644 index 0000000000..2c0b2f0fee --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { c as _c } from 'react/compiler-runtime' +type Props = { + /** + * Pre-rendered ANSI lines. Each element must be exactly one terminal row + * (already wrapped to `width` by the producer) with ANSI escape codes inline. + */ + lines: string[] + /** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */ + width: number +} + +/** + * Bypass the → React tree → Yoga → squash → re-serialize roundtrip for + * content that is already terminal-ready. + * + * Use this when an external renderer (e.g. the ColorDiff NAPI module) has + * already produced ANSI-escaped, width-wrapped output. A normal mount + * reparses that output into one React per style span, lays out each + * span as a Yoga flex child, then walks the tree to re-emit the same escape + * codes it was given. For a long transcript full of syntax-highlighted diffs + * that roundtrip is the dominant cost of the render. + * + * This component emits a single Yoga leaf with a constant-time measure func + * (width × lines.length) and hands the joined string straight to output.write(), + * which already splits on '\n' and parses ANSI into the screen buffer. + */ +export function RawAnsi(t0) { + const $ = _c(6) + + const { lines, width } = t0 + + if (lines.length === 0) { + return null + } + + let t1 + + if ($[0] !== lines) { + t1 = lines.join('\n') + $[0] = lines + $[1] = t1 + } else { + t1 = $[1] + } + + let t2 + + if ($[2] !== lines.length || $[3] !== t1 || $[4] !== width) { + t2 = + $[2] = lines.length + $[3] = t1 + $[4] = width + $[5] = t2 + } else { + t2 = $[5] + } + + return t2 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzIiwibGluZXMiLCJ3aWR0aCIsIlJhd0Fuc2kiLCJ0MCIsIiQiLCJfYyIsImxlbmd0aCIsInQxIiwiam9pbiIsInQyIl0sInNvdXJjZXMiOlsiUmF3QW5zaS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuXG50eXBlIFByb3BzID0ge1xuICAvKipcbiAgICogUHJlLXJlbmRlcmVkIEFOU0kgbGluZXMuIEVhY2ggZWxlbWVudCBtdXN0IGJlIGV4YWN0bHkgb25lIHRlcm1pbmFsIHJvd1xuICAgKiAoYWxyZWFkeSB3cmFwcGVkIHRvIGB3aWR0aGAgYnkgdGhlIHByb2R1Y2VyKSB3aXRoIEFOU0kgZXNjYXBlIGNvZGVzIGlubGluZS5cbiAgICovXG4gIGxpbmVzOiBzdHJpbmdbXVxuICAvKiogQ29sdW1uIHdpZHRoIHRoZSBwcm9kdWNlciB3cmFwcGVkIHRvLiBTZW50IHRvIFlvZ2EgYXMgdGhlIGZpeGVkIGxlYWYgd2lkdGguICovXG4gIHdpZHRoOiBudW1iZXJcbn1cblxuLyoqXG4gKiBCeXBhc3MgdGhlIDxBbnNpPiDihpIgUmVhY3QgdHJlZSDihpIgWW9nYSDihpIgc3F1YXNoIOKGkiByZS1zZXJpYWxpemUgcm91bmR0cmlwIGZvclxuICogY29udGVudCB0aGF0IGlzIGFscmVhZHkgdGVybWluYWwtcmVhZHkuXG4gKlxuICogVXNlIHRoaXMgd2hlbiBhbiBleHRlcm5hbCByZW5kZXJlciAoZS5nLiB0aGUgQ29sb3JEaWZmIE5BUEkgbW9kdWxlKSBoYXNcbiAqIGFscmVhZHkgcHJvZHVjZWQgQU5TSS1lc2NhcGVkLCB3aWR0aC13cmFwcGVkIG91dHB1dC4gQSBub3JtYWwgPEFuc2k+IG1vdW50XG4gKiByZXBhcnNlcyB0aGF0IG91dHB1dCBpbnRvIG9uZSBSZWFjdCA8VGV4dD4gcGVyIHN0eWxlIHNwYW4sIGxheXMgb3V0IGVhY2hcbiAqIHNwYW4gYXMgYSBZb2dhIGZsZXggY2hpbGQsIHRoZW4gd2Fsa3MgdGhlIHRyZWUgdG8gcmUtZW1pdCB0aGUgc2FtZSBlc2NhcGVcbiAqIGNvZGVzIGl0IHdhcyBnaXZlbi4gRm9yIGEgbG9uZyB0cmFuc2NyaXB0IGZ1bGwgb2Ygc3ludGF4LWhpZ2hsaWdodGVkIGRpZmZzXG4gKiB0aGF0IHJvdW5kdHJpcCBpcyB0aGUgZG9taW5hbnQgY29zdCBvZiB0aGUgcmVuZGVyLlxuICpcbiAqIFRoaXMgY29tcG9uZW50IGVtaXRzIGEgc2luZ2xlIFlvZ2EgbGVhZiB3aXRoIGEgY29uc3RhbnQtdGltZSBtZWFzdXJlIGZ1bmNcbiAqICh3aWR0aCDDlyBsaW5lcy5sZW5ndGgpIGFuZCBoYW5kcyB0aGUgam9pbmVkIHN0cmluZyBzdHJhaWdodCB0byBvdXRwdXQud3JpdGUoKSxcbiAqIHdoaWNoIGFscmVhZHkgc3BsaXRzIG9uICdcXG4nIGFuZCBwYXJzZXMgQU5TSSBpbnRvIHRoZSBzY3JlZW4gYnVmZmVyLlxuICovXG5leHBvcnQgZnVuY3Rpb24gUmF3QW5zaSh7IGxpbmVzLCB3aWR0aCB9OiBQcm9wcyk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGlmIChsaW5lcy5sZW5ndGggPT09IDApIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG4gIHJldHVybiAoXG4gICAgPGluay1yYXctYW5zaVxuICAgICAgcmF3VGV4dD17bGluZXMuam9pbignXFxuJyl9XG4gICAgICByYXdXaWR0aD17d2lkdGh9XG4gICAgICByYXdIZWlnaHQ9e2xpbmVzLmxlbmd0aH1cbiAgICAvPlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUV6QixLQUFLQyxLQUFLLEdBQUc7RUFDWDtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxLQUFLLEVBQUUsTUFBTSxFQUFFO0VBQ2Y7RUFDQUMsS0FBSyxFQUFFLE1BQU07QUFDZixDQUFDOztBQUVEO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLE9BQU8sU0FBQUMsUUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFpQjtJQUFBTCxLQUFBO0lBQUFDO0VBQUEsSUFBQUUsRUFBdUI7RUFDN0MsSUFBSUgsS0FBSyxDQUFBTSxNQUFPLEtBQUssQ0FBQztJQUFBLE9BQ2IsSUFBSTtFQUFBO0VBQ1osSUFBQUMsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQUosS0FBQTtJQUdZTyxFQUFBLEdBQUFQLEtBQUssQ0FBQVEsSUFBSyxDQUFDLElBQUksQ0FBQztJQUFBSixDQUFBLE1BQUFKLEtBQUE7SUFBQUksQ0FBQSxNQUFBRyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBSCxDQUFBO0VBQUE7RUFBQSxJQUFBSyxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBSixLQUFBLENBQUFNLE1BQUEsSUFBQUYsQ0FBQSxRQUFBRyxFQUFBLElBQUFILENBQUEsUUFBQUgsS0FBQTtJQUQzQlEsRUFBQSxnQkFJRSxDQUhTLE9BQWdCLENBQWhCLENBQUFGLEVBQWUsQ0FBQyxDQUNmTixRQUFLLENBQUxBLE1BQUksQ0FBQyxDQUNKLFNBQVksQ0FBWixDQUFBRCxLQUFLLENBQUFNLE1BQU0sQ0FBQyxHQUN2QjtJQUFBRixDQUFBLE1BQUFKLEtBQUEsQ0FBQU0sTUFBQTtJQUFBRixDQUFBLE1BQUFHLEVBQUE7SUFBQUgsQ0FBQSxNQUFBSCxLQUFBO0lBQUFHLENBQUEsTUFBQUssRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUwsQ0FBQTtFQUFBO0VBQUEsT0FKRkssRUFJRTtBQUFBIiwiaWdub3JlTGlzdCI6W119 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx new file mode 100644 index 0000000000..e7b55e71d6 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -0,0 +1,285 @@ +import '../global.d.ts' + +import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react' +import type { Except } from 'type-fest' + +import { markScrollActivity } from '../../bootstrap/state.js' +import type { DOMElement } from '../dom.js' +import { markDirty, scheduleRenderFrom } from '../dom.js' +import { markCommitStart } from '../reconciler.js' +import type { Styles } from '../styles.js' + +import Box from './Box.js' +export type ScrollBoxHandle = { + scrollTo: (y: number) => void + scrollBy: (dy: number) => void + /** + * Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike + * scrollTo which bakes a number that's stale by the time the throttled + * render fires, this defers the position read to render time — + * render-node-to-output reads `el.yogaNode.getComputedTop()` in the + * SAME Yoga pass that computes scrollHeight. Deterministic. One-shot. + */ + scrollToElement: (el: DOMElement, offset?: number) => void + scrollToBottom: () => void + getScrollTop: () => number + getPendingDelta: () => number + getScrollHeight: () => number + /** + * Like getScrollHeight, but reads Yoga directly instead of the cached + * value written by render-node-to-output (throttled, up to 16ms stale). + * Use when you need a fresh value in useLayoutEffect after a React commit + * that grew content. Slightly more expensive (native Yoga call). + */ + getFreshScrollHeight: () => number + getViewportHeight: () => number + /** + * Absolute screen-buffer row of the first visible content line (inside + * padding). Used for drag-to-scroll edge detection. + */ + getViewportTop: () => number + /** + * True when scroll is pinned to the bottom. Set by scrollToBottom, the + * initial stickyScroll attribute, and by the renderer when positional + * follow fires (scrollTop at prevMax, content grows). Cleared by + * scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on + * layout values (unlike scrollTop+viewportH >= scrollHeight). + */ + isSticky: () => boolean + /** + * Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom). + * Does NOT fire for stickyScroll updates done by the Ink renderer — those + * happen during Ink's render phase after React has committed. Callers that + * care about the sticky case should treat "at bottom" as a fallback. + */ + subscribe: (listener: () => void) => () => void + /** + * Set the render-time scrollTop clamp to the currently-mounted children's + * coverage span. Called by useVirtualScroll after computing its range; + * render-node-to-output clamps scrollTop to [min, max] so burst scrollTo + * calls that race past React's async re-render show the edge of mounted + * content instead of blank spacer. Pass undefined to disable (sticky, + * cold start). + */ + setClampBounds: (min: number | undefined, max: number | undefined) => void +} +export type ScrollBoxProps = Except & { + ref?: Ref + /** + * When true, automatically pins scroll position to the bottom when content + * grows. Unset manually via scrollTo/scrollBy to break the stickiness. + */ + stickyScroll?: boolean +} + +/** + * A Box with `overflow: scroll` and an imperative scroll API. + * + * Children are laid out at their full Yoga-computed height inside a + * constrained container. At render time, only children intersecting the + * visible window (scrollTop..scrollTop+height) are rendered (viewport + * culling). Content is translated by -scrollTop and clipped to the box bounds. + * + * Works best inside a fullscreen (constrained-height root) Ink tree. + */ +function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren): React.ReactNode { + const domRef = useRef(null) + // scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node, + // mark it dirty, and call the root's throttled scheduleRender directly. + // The Ink renderer reads scrollTop from the node — no React state needed, + // no reconciler overhead per wheel event. The microtask defer coalesces + // multiple scrollBy calls in one input batch (discreteUpdates) into one + // render — otherwise scheduleRender's leading edge fires on the FIRST + // event before subsequent events mutate scrollTop. scrollToBottom still + // forces a React render: sticky is attribute-observed, no DOM-only path. + const [, forceRender] = useState(0) + const listenersRef = useRef(new Set<() => void>()) + const renderQueuedRef = useRef(false) + + const notify = () => { + for (const l of listenersRef.current) { + l() + } + } + + function scrollMutated(el: DOMElement): void { + // Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan + // check) to skip their next tick — they compete for the event loop and + // contributed to 1402ms max frame gaps during scroll drain. + markScrollActivity() + markDirty(el) + markCommitStart() + notify() + + if (renderQueuedRef.current) { + return + } + + renderQueuedRef.current = true + queueMicrotask(() => { + renderQueuedRef.current = false + scheduleRenderFrom(el) + }) + } + + useImperativeHandle( + ref, + (): ScrollBoxHandle => ({ + scrollTo(y: number) { + const el = domRef.current + + if (!el) { + return + } + + // Explicit false overrides the DOM attribute so manual scroll + // breaks stickiness. Render code checks ?? precedence. + el.stickyScroll = false + el.pendingScrollDelta = undefined + el.scrollAnchor = undefined + el.scrollTop = Math.max(0, Math.floor(y)) + scrollMutated(el) + }, + scrollToElement(el: DOMElement, offset = 0) { + const box = domRef.current + + if (!box) { + return + } + + box.stickyScroll = false + box.pendingScrollDelta = undefined + box.scrollAnchor = { + el, + offset + } + scrollMutated(box) + }, + scrollBy(dy: number) { + const el = domRef.current + + if (!el) { + return + } + + el.stickyScroll = false + // Wheel input cancels any in-flight anchor seek — user override. + el.scrollAnchor = undefined + // Accumulate in pendingScrollDelta; renderer drains it at a capped + // rate so fast flicks show intermediate frames. Pure accumulator: + // scroll-up followed by scroll-down naturally cancels. + el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy) + scrollMutated(el) + }, + scrollToBottom() { + const el = domRef.current + + if (!el) { + return + } + + el.pendingScrollDelta = undefined + el.stickyScroll = true + markDirty(el) + notify() + forceRender(n => n + 1) + }, + getScrollTop() { + return domRef.current?.scrollTop ?? 0 + }, + getPendingDelta() { + // Accumulated-but-not-yet-drained delta. useVirtualScroll needs + // this to mount the union [committed, committed+pending] range — + // otherwise intermediate drain frames find no children (blank). + return domRef.current?.pendingScrollDelta ?? 0 + }, + getScrollHeight() { + return domRef.current?.scrollHeight ?? 0 + }, + getFreshScrollHeight() { + const content = domRef.current?.childNodes[0] as DOMElement | undefined + + return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0 + }, + getViewportHeight() { + return domRef.current?.scrollViewportHeight ?? 0 + }, + getViewportTop() { + return domRef.current?.scrollViewportTop ?? 0 + }, + isSticky() { + const el = domRef.current + + if (!el) { + return false + } + + return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']) + }, + subscribe(listener: () => void) { + listenersRef.current.add(listener) + + return () => listenersRef.current.delete(listener) + }, + setClampBounds(min, max) { + const el = domRef.current + + if (!el) { + return + } + + el.scrollClampMin = min + el.scrollClampMax = max + } + }), + // notify/scrollMutated are inline (no useCallback) but only close over + // refs + imports — stable. Empty deps avoids rebuilding the handle on + // every render (which re-registers the ref = churn). + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + // Structure: outer viewport (overflow:scroll, constrained height) > + // inner content (flexGrow:1, flexShrink:0 — fills at least the viewport + // but grows beyond it for tall content). flexGrow:1 lets children use + // spacers to pin elements to the bottom of the scroll area. Yoga's + // Overflow.Scroll prevents the viewport from growing to fit the content. + // The renderer computes scrollHeight from the content box and culls + // content's children based on scrollTop. + // + // stickyScroll is passed as a DOM attribute (via ink-box directly) so it's + // available on the first render — ref callbacks fire after the initial + // commit, which is too late for the first frame. + return ( + { + domRef.current = el + + if (el) { + el.scrollTop ??= 0 + } + }} + style={{ + flexWrap: 'nowrap', + flexDirection: style.flexDirection ?? 'row', + flexGrow: style.flexGrow ?? 0, + flexShrink: style.flexShrink ?? 1, + ...style, + overflowX: 'scroll', + overflowY: 'scroll' + }} + {...(stickyScroll + ? { + stickyScroll: true + } + : {})} + > + + {children} + + + ) +} + +export default ScrollBox +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlByb3BzV2l0aENoaWxkcmVuIiwiUmVmIiwidXNlSW1wZXJhdGl2ZUhhbmRsZSIsInVzZVJlZiIsInVzZVN0YXRlIiwiRXhjZXB0IiwibWFya1Njcm9sbEFjdGl2aXR5IiwiRE9NRWxlbWVudCIsIm1hcmtEaXJ0eSIsInNjaGVkdWxlUmVuZGVyRnJvbSIsIm1hcmtDb21taXRTdGFydCIsIlN0eWxlcyIsIkJveCIsIlNjcm9sbEJveEhhbmRsZSIsInNjcm9sbFRvIiwieSIsInNjcm9sbEJ5IiwiZHkiLCJzY3JvbGxUb0VsZW1lbnQiLCJlbCIsIm9mZnNldCIsInNjcm9sbFRvQm90dG9tIiwiZ2V0U2Nyb2xsVG9wIiwiZ2V0UGVuZGluZ0RlbHRhIiwiZ2V0U2Nyb2xsSGVpZ2h0IiwiZ2V0RnJlc2hTY3JvbGxIZWlnaHQiLCJnZXRWaWV3cG9ydEhlaWdodCIsImdldFZpZXdwb3J0VG9wIiwiaXNTdGlja3kiLCJzdWJzY3JpYmUiLCJsaXN0ZW5lciIsInNldENsYW1wQm91bmRzIiwibWluIiwibWF4IiwiU2Nyb2xsQm94UHJvcHMiLCJyZWYiLCJzdGlja3lTY3JvbGwiLCJTY3JvbGxCb3giLCJjaGlsZHJlbiIsInN0eWxlIiwiUmVhY3ROb2RlIiwiZG9tUmVmIiwiZm9yY2VSZW5kZXIiLCJsaXN0ZW5lcnNSZWYiLCJTZXQiLCJyZW5kZXJRdWV1ZWRSZWYiLCJub3RpZnkiLCJsIiwiY3VycmVudCIsInNjcm9sbE11dGF0ZWQiLCJxdWV1ZU1pY3JvdGFzayIsInBlbmRpbmdTY3JvbGxEZWx0YSIsInVuZGVmaW5lZCIsInNjcm9sbEFuY2hvciIsInNjcm9sbFRvcCIsIk1hdGgiLCJmbG9vciIsImJveCIsIm4iLCJzY3JvbGxIZWlnaHQiLCJjb250ZW50IiwiY2hpbGROb2RlcyIsInlvZ2FOb2RlIiwiZ2V0Q29tcHV0ZWRIZWlnaHQiLCJzY3JvbGxWaWV3cG9ydEhlaWdodCIsInNjcm9sbFZpZXdwb3J0VG9wIiwiQm9vbGVhbiIsImF0dHJpYnV0ZXMiLCJhZGQiLCJkZWxldGUiLCJzY3JvbGxDbGFtcE1pbiIsInNjcm9sbENsYW1wTWF4IiwiZmxleFdyYXAiLCJmbGV4RGlyZWN0aW9uIiwiZmxleEdyb3ciLCJmbGV4U2hyaW5rIiwib3ZlcmZsb3dYIiwib3ZlcmZsb3dZIl0sInNvdXJjZXMiOlsiU2Nyb2xsQm94LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QsIHtcbiAgdHlwZSBQcm9wc1dpdGhDaGlsZHJlbixcbiAgdHlwZSBSZWYsXG4gIHVzZUltcGVyYXRpdmVIYW5kbGUsXG4gIHVzZVJlZixcbiAgdXNlU3RhdGUsXG59IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBFeGNlcHQgfSBmcm9tICd0eXBlLWZlc3QnXG5pbXBvcnQgeyBtYXJrU2Nyb2xsQWN0aXZpdHkgfSBmcm9tICcuLi8uLi9ib290c3RyYXAvc3RhdGUuanMnXG5pbXBvcnQgdHlwZSB7IERPTUVsZW1lbnQgfSBmcm9tICcuLi9kb20uanMnXG5pbXBvcnQgeyBtYXJrRGlydHksIHNjaGVkdWxlUmVuZGVyRnJvbSB9IGZyb20gJy4uL2RvbS5qcydcbmltcG9ydCB7IG1hcmtDb21taXRTdGFydCB9IGZyb20gJy4uL3JlY29uY2lsZXIuanMnXG5pbXBvcnQgdHlwZSB7IFN0eWxlcyB9IGZyb20gJy4uL3N0eWxlcy5qcydcbmltcG9ydCAnLi4vZ2xvYmFsLmQudHMnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG5leHBvcnQgdHlwZSBTY3JvbGxCb3hIYW5kbGUgPSB7XG4gIHNjcm9sbFRvOiAoeTogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbEJ5OiAoZHk6IG51bWJlcikgPT4gdm9pZFxuICAvKipcbiAgICogU2Nyb2xsIHNvIGBlbGAncyB0b3AgaXMgYXQgdGhlIHZpZXdwb3J0IHRvcCAocGx1cyBgb2Zmc2V0YCkuIFVubGlrZVxuICAgKiBzY3JvbGxUbyB3aGljaCBiYWtlcyBhIG51bWJlciB0aGF0J3Mgc3RhbGUgYnkgdGhlIHRpbWUgdGhlIHRocm90dGxlZFxuICAgKiByZW5kZXIgZmlyZXMsIHRoaXMgZGVmZXJzIHRoZSBwb3NpdGlvbiByZWFkIHRvIHJlbmRlciB0aW1lIOKAlFxuICAgKiByZW5kZXItbm9kZS10by1vdXRwdXQgcmVhZHMgYGVsLnlvZ2FOb2RlLmdldENvbXB1dGVkVG9wKClgIGluIHRoZVxuICAgKiBTQU1FIFlvZ2EgcGFzcyB0aGF0IGNvbXB1dGVzIHNjcm9sbEhlaWdodC4gRGV0ZXJtaW5pc3RpYy4gT25lLXNob3QuXG4gICAqL1xuICBzY3JvbGxUb0VsZW1lbnQ6IChlbDogRE9NRWxlbWVudCwgb2Zmc2V0PzogbnVtYmVyKSA9PiB2b2lkXG4gIHNjcm9sbFRvQm90dG9tOiAoKSA9PiB2b2lkXG4gIGdldFNjcm9sbFRvcDogKCkgPT4gbnVtYmVyXG4gIGdldFBlbmRpbmdEZWx0YTogKCkgPT4gbnVtYmVyXG4gIGdldFNjcm9sbEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBMaWtlIGdldFNjcm9sbEhlaWdodCwgYnV0IHJlYWRzIFlvZ2EgZGlyZWN0bHkgaW5zdGVhZCBvZiB0aGUgY2FjaGVkXG4gICAqIHZhbHVlIHdyaXR0ZW4gYnkgcmVuZGVyLW5vZGUtdG8tb3V0cHV0ICh0aHJvdHRsZWQsIHVwIHRvIDE2bXMgc3RhbGUpLlxuICAgKiBVc2Ugd2hlbiB5b3UgbmVlZCBhIGZyZXNoIHZhbHVlIGluIHVzZUxheW91dEVmZmVjdCBhZnRlciBhIFJlYWN0IGNvbW1pdFxuICAgKiB0aGF0IGdyZXcgY29udGVudC4gU2xpZ2h0bHkgbW9yZSBleHBlbnNpdmUgKG5hdGl2ZSBZb2dhIGNhbGwpLlxuICAgKi9cbiAgZ2V0RnJlc2hTY3JvbGxIZWlnaHQ6ICgpID0+IG51bWJlclxuICBnZXRWaWV3cG9ydEhlaWdodDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBBYnNvbHV0ZSBzY3JlZW4tYnVmZmVyIHJvdyBvZiB0aGUgZmlyc3QgdmlzaWJsZSBjb250ZW50IGxpbmUgKGluc2lkZVxuICAgKiBwYWRkaW5nKS4gVXNlZCBmb3IgZHJhZy10by1zY3JvbGwgZWRnZSBkZXRlY3Rpb24uXG4gICAqL1xuICBnZXRWaWV3cG9ydFRvcDogKCkgPT4gbnVtYmVyXG4gIC8qKlxuICAgKiBUcnVlIHdoZW4gc2Nyb2xsIGlzIHBpbm5lZCB0byB0aGUgYm90dG9tLiBTZXQgYnkgc2Nyb2xsVG9Cb3R0b20sIHRoZVxuICAgKiBpbml0aWFsIHN0aWNreVNjcm9sbCBhdHRyaWJ1dGUsIGFuZCBieSB0aGUgcmVuZGVyZXIgd2hlbiBwb3NpdGlvbmFsXG4gICAqIGZvbGxvdyBmaXJlcyAoc2Nyb2xsVG9wIGF0IHByZXZNYXgsIGNvbnRlbnQgZ3Jvd3MpLiBDbGVhcmVkIGJ5XG4gICAqIHNjcm9sbFRvL3Njcm9sbEJ5LiBTdGFibGUgc2lnbmFsIGZvciBcImF0IGJvdHRvbVwiIHRoYXQgZG9lc24ndCBkZXBlbmQgb25cbiAgICogbGF5b3V0IHZhbHVlcyAodW5saWtlIHNjcm9sbFRvcCt2aWV3cG9ydEggPj0gc2Nyb2xsSGVpZ2h0KS5cbiAgICovXG4gIGlzU3RpY2t5OiAoKSA9PiBib29sZWFuXG4gIC8qKlxuICAgKiBTdWJzY3JpYmUgdG8gaW1wZXJhdGl2ZSBzY3JvbGwgY2hhbmdlcyAoc2Nyb2xsVG8vc2Nyb2xsQnkvc2Nyb2xsVG9Cb3R0b20pLlxuICAgKiBEb2VzIE5PVCBmaXJlIGZvciBzdGlja3lTY3JvbGwgdXBkYXRlcyBkb25lIGJ5IHRoZSBJbmsgcmVuZGVyZXIg4oCUIHRob3NlXG4gICAqIGhhcHBlbiBkdXJpbmcgSW5rJ3MgcmVuZGVyIHBoYXNlIGFmdGVyIFJlYWN0IGhhcyBjb21taXR0ZWQuIENhbGxlcnMgdGhhdFxuICAgKiBjYXJlIGFib3V0IHRoZSBzdGlja3kgY2FzZSBzaG91bGQgdHJlYXQgXCJhdCBib3R0b21cIiBhcyBhIGZhbGxiYWNrLlxuICAgKi9cbiAgc3Vic2NyaWJlOiAobGlzdGVuZXI6ICgpID0+IHZvaWQpID0+ICgpID0+IHZvaWRcbiAgLyoqXG4gICAqIFNldCB0aGUgcmVuZGVyLXRpbWUgc2Nyb2xsVG9wIGNsYW1wIHRvIHRoZSBjdXJyZW50bHktbW91bnRlZCBjaGlsZHJlbidzXG4gICAqIGNvdmVyYWdlIHNwYW4uIENhbGxlZCBieSB1c2VWaXJ0dWFsU2Nyb2xsIGFmdGVyIGNvbXB1dGluZyBpdHMgcmFuZ2U7XG4gICAqIHJlbmRlci1ub2RlLXRvLW91dHB1dCBjbGFtcHMgc2Nyb2xsVG9wIHRvIFttaW4sIG1heF0gc28gYnVyc3Qgc2Nyb2xsVG9cbiAgICogY2FsbHMgdGhhdCByYWNlIHBhc3QgUmVhY3QncyBhc3luYyByZS1yZW5kZXIgc2hvdyB0aGUgZWRnZSBvZiBtb3VudGVkXG4gICAqIGNvbnRlbnQgaW5zdGVhZCBvZiBibGFuayBzcGFjZXIuIFBhc3MgdW5kZWZpbmVkIHRvIGRpc2FibGUgKHN0aWNreSxcbiAgICogY29sZCBzdGFydCkuXG4gICAqL1xuICBzZXRDbGFtcEJvdW5kczogKG1pbjogbnVtYmVyIHwgdW5kZWZpbmVkLCBtYXg6IG51bWJlciB8IHVuZGVmaW5lZCkgPT4gdm9pZFxufVxuXG5leHBvcnQgdHlwZSBTY3JvbGxCb3hQcm9wcyA9IEV4Y2VwdDxcbiAgU3R5bGVzLFxuICAndGV4dFdyYXAnIHwgJ292ZXJmbG93JyB8ICdvdmVyZmxvd1gnIHwgJ292ZXJmbG93WSdcbj4gJiB7XG4gIHJlZj86IFJlZjxTY3JvbGxCb3hIYW5kbGU+XG4gIC8qKlxuICAgKiBXaGVuIHRydWUsIGF1dG9tYXRpY2FsbHkgcGlucyBzY3JvbGwgcG9zaXRpb24gdG8gdGhlIGJvdHRvbSB3aGVuIGNvbnRlbnRcbiAgICogZ3Jvd3MuIFVuc2V0IG1hbnVhbGx5IHZpYSBzY3JvbGxUby9zY3JvbGxCeSB0byBicmVhayB0aGUgc3RpY2tpbmVzcy5cbiAgICovXG4gIHN0aWNreVNjcm9sbD86IGJvb2xlYW5cbn1cblxuLyoqXG4gKiBBIEJveCB3aXRoIGBvdmVyZmxvdzogc2Nyb2xsYCBhbmQgYW4gaW1wZXJhdGl2ZSBzY3JvbGwgQVBJLlxuICpcbiAqIENoaWxkcmVuIGFyZSBsYWlkIG91dCBhdCB0aGVpciBmdWxsIFlvZ2EtY29tcHV0ZWQgaGVpZ2h0IGluc2lkZSBhXG4gKiBjb25zdHJhaW5lZCBjb250YWluZXIuIEF0IHJlbmRlciB0aW1lLCBvbmx5IGNoaWxkcmVuIGludGVyc2VjdGluZyB0aGVcbiAqIHZpc2libGUgd2luZG93IChzY3JvbGxUb3AuLnNjcm9sbFRvcCtoZWlnaHQpIGFyZSByZW5kZXJlZCAodmlld3BvcnRcbiAqIGN1bGxpbmcpLiBDb250ZW50IGlzIHRyYW5zbGF0ZWQgYnkgLXNjcm9sbFRvcCBhbmQgY2xpcHBlZCB0byB0aGUgYm94IGJvdW5kcy5cbiAqXG4gKiBXb3JrcyBiZXN0IGluc2lkZSBhIGZ1bGxzY3JlZW4gKGNvbnN0cmFpbmVkLWhlaWdodCByb290KSBJbmsgdHJlZS5cbiAqL1xuZnVuY3Rpb24gU2Nyb2xsQm94KHtcbiAgY2hpbGRyZW4sXG4gIHJlZixcbiAgc3RpY2t5U2Nyb2xsLFxuICAuLi5zdHlsZVxufTogUHJvcHNXaXRoQ2hpbGRyZW48U2Nyb2xsQm94UHJvcHM+KTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgY29uc3QgZG9tUmVmID0gdXNlUmVmPERPTUVsZW1lbnQ+KG51bGwpXG4gIC8vIHNjcm9sbFRvL3Njcm9sbEJ5IGJ5cGFzcyBSZWFjdDogdGhleSBtdXRhdGUgc2Nyb2xsVG9wIG9uIHRoZSBET00gbm9kZSxcbiAgLy8gbWFyayBpdCBkaXJ0eSwgYW5kIGNhbGwgdGhlIHJvb3QncyB0aHJvdHRsZWQgc2NoZWR1bGVSZW5kZXIgZGlyZWN0bHkuXG4gIC8vIFRoZSBJbmsgcmVuZGVyZXIgcmVhZHMgc2Nyb2xsVG9wIGZyb20gdGhlIG5vZGUg4oCUIG5vIFJlYWN0IHN0YXRlIG5lZWRlZCxcbiAgLy8gbm8gcmVjb25jaWxlciBvdmVyaGVhZCBwZXIgd2hlZWwgZXZlbnQuIFRoZSBtaWNyb3Rhc2sgZGVmZXIgY29hbGVzY2VzXG4gIC8vIG11bHRpcGxlIHNjcm9sbEJ5IGNhbGxzIGluIG9uZSBpbnB1dCBiYXRjaCAoZGlzY3JldGVVcGRhdGVzKSBpbnRvIG9uZVxuICAvLyByZW5kZXIg4oCUIG90aGVyd2lzZSBzY2hlZHVsZVJlbmRlcidzIGxlYWRpbmcgZWRnZSBmaXJlcyBvbiB0aGUgRklSU1RcbiAgLy8gZXZlbnQgYmVmb3JlIHN1YnNlcXVlbnQgZXZlbnRzIG11dGF0ZSBzY3JvbGxUb3AuIHNjcm9sbFRvQm90dG9tIHN0aWxsXG4gIC8vIGZvcmNlcyBhIFJlYWN0IHJlbmRlcjogc3RpY2t5IGlzIGF0dHJpYnV0ZS1vYnNlcnZlZCwgbm8gRE9NLW9ubHkgcGF0aC5cbiAgY29uc3QgWywgZm9yY2VSZW5kZXJdID0gdXNlU3RhdGUoMClcbiAgY29uc3QgbGlzdGVuZXJzUmVmID0gdXNlUmVmKG5ldyBTZXQ8KCkgPT4gdm9pZD4oKSlcbiAgY29uc3QgcmVuZGVyUXVldWVkUmVmID0gdXNlUmVmKGZhbHNlKVxuXG4gIGNvbnN0IG5vdGlmeSA9ICgpID0+IHtcbiAgICBmb3IgKGNvbnN0IGwgb2YgbGlzdGVuZXJzUmVmLmN1cnJlbnQpIGwoKVxuICB9XG5cbiAgZnVuY3Rpb24gc2Nyb2xsTXV0YXRlZChlbDogRE9NRWxlbWVudCk6IHZvaWQge1xuICAgIC8vIFNpZ25hbCBiYWNrZ3JvdW5kIGludGVydmFscyAoSURFIHBvbGwsIExTUCBwb2xsLCBHQ1MgZmV0Y2gsIG9ycGhhblxuICAgIC8vIGNoZWNrKSB0byBza2lwIHRoZWlyIG5leHQgdGljayDigJQgdGhleSBjb21wZXRlIGZvciB0aGUgZXZlbnQgbG9vcCBhbmRcbiAgICAvLyBjb250cmlidXRlZCB0byAxNDAybXMgbWF4IGZyYW1lIGdhcHMgZHVyaW5nIHNjcm9sbCBkcmFpbi5cbiAgICBtYXJrU2Nyb2xsQWN0aXZpdHkoKVxuICAgIG1hcmtEaXJ0eShlbClcbiAgICBtYXJrQ29tbWl0U3RhcnQoKVxuICAgIG5vdGlmeSgpXG4gICAgaWYgKHJlbmRlclF1ZXVlZFJlZi5jdXJyZW50KSByZXR1cm5cbiAgICByZW5kZXJRdWV1ZWRSZWYuY3VycmVudCA9IHRydWVcbiAgICBxdWV1ZU1pY3JvdGFzaygoKSA9PiB7XG4gICAgICByZW5kZXJRdWV1ZWRSZWYuY3VycmVudCA9IGZhbHNlXG4gICAgICBzY2hlZHVsZVJlbmRlckZyb20oZWwpXG4gICAgfSlcbiAgfVxuXG4gIHVzZUltcGVyYXRpdmVIYW5kbGUoXG4gICAgcmVmLFxuICAgICgpOiBTY3JvbGxCb3hIYW5kbGUgPT4gKHtcbiAgICAgIHNjcm9sbFRvKHk6IG51bWJlcikge1xuICAgICAgICBjb25zdCBlbCA9IGRvbVJlZi5jdXJyZW50XG4gICAgICAgIGlmICghZWwpIHJldHVyblxuICAgICAgICAvLyBFeHBsaWNpdCBmYWxzZSBvdmVycmlkZXMgdGhlIERPTSBhdHRyaWJ1dGUgc28gbWFudWFsIHNjcm9sbFxuICAgICAgICAvLyBicmVha3Mgc3RpY2tpbmVzcy4gUmVuZGVyIGNvZGUgY2hlY2tzID8/IHByZWNlZGVuY2UuXG4gICAgICAgIGVsLnN0aWNreVNjcm9sbCA9IGZhbHNlXG4gICAgICAgIGVsLnBlbmRpbmdTY3JvbGxEZWx0YSA9IHVuZGVmaW5lZFxuICAgICAgICBlbC5zY3JvbGxBbmNob3IgPSB1bmRlZmluZWRcbiAgICAgICAgZWwuc2Nyb2xsVG9wID0gTWF0aC5tYXgoMCwgTWF0aC5mbG9vcih5KSlcbiAgICAgICAgc2Nyb2xsTXV0YXRlZChlbClcbiAgICAgIH0sXG4gICAgICBzY3JvbGxUb0VsZW1lbnQoZWw6IERPTUVsZW1lbnQsIG9mZnNldCA9IDApIHtcbiAgICAgICAgY29uc3QgYm94ID0gZG9tUmVmLmN1cnJlbnRcbiAgICAgICAgaWYgKCFib3gpIHJldHVyblxuICAgICAgICBib3guc3RpY2t5U2Nyb2xsID0gZmFsc2VcbiAgICAgICAgYm94LnBlbmRpbmdTY3JvbGxEZWx0YSA9IHVuZGVmaW5lZFxuICAgICAgICBib3guc2Nyb2xsQW5jaG9yID0geyBlbCwgb2Zmc2V0IH1cbiAgICAgICAgc2Nyb2xsTXV0YXRlZChib3gpXG4gICAgICB9LFxuICAgICAgc2Nyb2xsQnkoZHk6IG51bWJlcikge1xuICAgICAgICBjb25zdCBlbCA9IGRvbVJlZi5jdXJyZW50XG4gICAgICAgIGlmICghZWwpIHJldHVyblxuICAgICAgICBlbC5zdGlja3lTY3JvbGwgPSBmYWxzZVxuICAgICAgICAvLyBXaGVlbCBpbnB1dCBjYW5jZWxzIGFueSBpbi1mbGlnaHQgYW5jaG9yIHNlZWsg4oCUIHVzZXIgb3ZlcnJpZGUuXG4gICAgICAgIGVsLnNjcm9sbEFuY2hvciA9IHVuZGVmaW5lZFxuICAgICAgICAvLyBBY2N1bXVsYXRlIGluIHBlbmRpbmdTY3JvbGxEZWx0YTsgcmVuZGVyZXIgZHJhaW5zIGl0IGF0IGEgY2FwcGVkXG4gICAgICAgIC8vIHJhdGUgc28gZmFzdCBmbGlja3Mgc2hvdyBpbnRlcm1lZGlhdGUgZnJhbWVzLiBQdXJlIGFjY3VtdWxhdG9yOlxuICAgICAgICAvLyBzY3JvbGwtdXAgZm9sbG93ZWQgYnkgc2Nyb2xsLWRvd24gbmF0dXJhbGx5IGNhbmNlbHMuXG4gICAgICAgIGVsLnBlbmRpbmdTY3JvbGxEZWx0YSA9IChlbC5wZW5kaW5nU2Nyb2xsRGVsdGEgPz8gMCkgKyBNYXRoLmZsb29yKGR5KVxuICAgICAgICBzY3JvbGxNdXRhdGVkKGVsKVxuICAgICAgfSxcbiAgICAgIHNjcm9sbFRvQm90dG9tKCkge1xuICAgICAgICBjb25zdCBlbCA9IGRvbVJlZi5jdXJyZW50XG4gICAgICAgIGlmICghZWwpIHJldHVyblxuICAgICAgICBlbC5wZW5kaW5nU2Nyb2xsRGVsdGEgPSB1bmRlZmluZWRcbiAgICAgICAgZWwuc3RpY2t5U2Nyb2xsID0gdHJ1ZVxuICAgICAgICBtYXJrRGlydHkoZWwpXG4gICAgICAgIG5vdGlmeSgpXG4gICAgICAgIGZvcmNlUmVuZGVyKG4gPT4gbiArIDEpXG4gICAgICB9LFxuICAgICAgZ2V0U2Nyb2xsVG9wKCkge1xuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnNjcm9sbFRvcCA/PyAwXG4gICAgICB9LFxuICAgICAgZ2V0UGVuZGluZ0RlbHRhKCkge1xuICAgICAgICAvLyBBY2N1bXVsYXRlZC1idXQtbm90LXlldC1kcmFpbmVkIGRlbHRhLiB1c2VWaXJ0dWFsU2Nyb2xsIG5lZWRzXG4gICAgICAgIC8vIHRoaXMgdG8gbW91bnQgdGhlIHVuaW9uIFtjb21taXR0ZWQsIGNvbW1pdHRlZCtwZW5kaW5nXSByYW5nZSDigJRcbiAgICAgICAgLy8gb3RoZXJ3aXNlIGludGVybWVkaWF0ZSBkcmFpbiBmcmFtZXMgZmluZCBubyBjaGlsZHJlbiAoYmxhbmspLlxuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnBlbmRpbmdTY3JvbGxEZWx0YSA/PyAwXG4gICAgICB9LFxuICAgICAgZ2V0U2Nyb2xsSGVpZ2h0KCkge1xuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnNjcm9sbEhlaWdodCA/PyAwXG4gICAgICB9LFxuICAgICAgZ2V0RnJlc2hTY3JvbGxIZWlnaHQoKSB7XG4gICAgICAgIGNvbnN0IGNvbnRlbnQgPSBkb21SZWYuY3VycmVudD8uY2hpbGROb2Rlc1swXSBhcyBET01FbGVtZW50IHwgdW5kZWZpbmVkXG4gICAgICAgIHJldHVybiAoXG4gICAgICAgICAgY29udGVudD8ueW9nYU5vZGU/LmdldENvbXB1dGVkSGVpZ2h0KCkgPz9cbiAgICAgICAgICBkb21SZWYuY3VycmVudD8uc2Nyb2xsSGVpZ2h0ID8/XG4gICAgICAgICAgMFxuICAgICAgICApXG4gICAgICB9LFxuICAgICAgZ2V0Vmlld3BvcnRIZWlnaHQoKSB7XG4gICAgICAgIHJldHVybiBkb21SZWYuY3VycmVudD8uc2Nyb2xsVmlld3BvcnRIZWlnaHQgPz8gMFxuICAgICAgfSxcbiAgICAgIGdldFZpZXdwb3J0VG9wKCkge1xuICAgICAgICByZXR1cm4gZG9tUmVmLmN1cnJlbnQ/LnNjcm9sbFZpZXdwb3J0VG9wID8/IDBcbiAgICAgIH0sXG4gICAgICBpc1N0aWNreSgpIHtcbiAgICAgICAgY29uc3QgZWwgPSBkb21SZWYuY3VycmVudFxuICAgICAgICBpZiAoIWVsKSByZXR1cm4gZmFsc2VcbiAgICAgICAgcmV0dXJuIGVsLnN0aWNreVNjcm9sbCA/PyBCb29sZWFuKGVsLmF0dHJpYnV0ZXNbJ3N0aWNreVNjcm9sbCddKVxuICAgICAgfSxcbiAgICAgIHN1YnNjcmliZShsaXN0ZW5lcjogKCkgPT4gdm9pZCkge1xuICAgICAgICBsaXN0ZW5lcnNSZWYuY3VycmVudC5hZGQobGlzdGVuZXIpXG4gICAgICAgIHJldHVybiAoKSA9PiBsaXN0ZW5lcnNSZWYuY3VycmVudC5kZWxldGUobGlzdGVuZXIpXG4gICAgICB9LFxuICAgICAgc2V0Q2xhbXBCb3VuZHMobWluLCBtYXgpIHtcbiAgICAgICAgY29uc3QgZWwgPSBkb21SZWYuY3VycmVudFxuICAgICAgICBpZiAoIWVsKSByZXR1cm5cbiAgICAgICAgZWwuc2Nyb2xsQ2xhbXBNaW4gPSBtaW5cbiAgICAgICAgZWwuc2Nyb2xsQ2xhbXBNYXggPSBtYXhcbiAgICAgIH0sXG4gICAgfSksXG4gICAgLy8gbm90aWZ5L3Njcm9sbE11dGF0ZWQgYXJlIGlubGluZSAobm8gdXNlQ2FsbGJhY2spIGJ1dCBvbmx5IGNsb3NlIG92ZXJcbiAgICAvLyByZWZzICsgaW1wb3J0cyDigJQgc3RhYmxlLiBFbXB0eSBkZXBzIGF2b2lkcyByZWJ1aWxkaW5nIHRoZSBoYW5kbGUgb25cbiAgICAvLyBldmVyeSByZW5kZXIgKHdoaWNoIHJlLXJlZ2lzdGVycyB0aGUgcmVmID0gY2h1cm4pLlxuICAgIC8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSByZWFjdC1ob29rcy9leGhhdXN0aXZlLWRlcHNcbiAgICBbXSxcbiAgKVxuXG4gIC8vIFN0cnVjdHVyZTogb3V0ZXIgdmlld3BvcnQgKG92ZXJmbG93OnNjcm9sbCwgY29uc3RyYWluZWQgaGVpZ2h0KSA+XG4gIC8vIGlubmVyIGNvbnRlbnQgKGZsZXhHcm93OjEsIGZsZXhTaHJpbms6MCDigJQgZmlsbHMgYXQgbGVhc3QgdGhlIHZpZXdwb3J0XG4gIC8vIGJ1dCBncm93cyBiZXlvbmQgaXQgZm9yIHRhbGwgY29udGVudCkuIGZsZXhHcm93OjEgbGV0cyBjaGlsZHJlbiB1c2VcbiAgLy8gc3BhY2VycyB0byBwaW4gZWxlbWVudHMgdG8gdGhlIGJvdHRvbSBvZiB0aGUgc2Nyb2xsIGFyZWEuIFlvZ2Enc1xuICAvLyBPdmVyZmxvdy5TY3JvbGwgcHJldmVudHMgdGhlIHZpZXdwb3J0IGZyb20gZ3Jvd2luZyB0byBmaXQgdGhlIGNvbnRlbnQuXG4gIC8vIFRoZSByZW5kZXJlciBjb21wdXRlcyBzY3JvbGxIZWlnaHQgZnJvbSB0aGUgY29udGVudCBib3ggYW5kIGN1bGxzXG4gIC8vIGNvbnRlbnQncyBjaGlsZHJlbiBiYXNlZCBvbiBzY3JvbGxUb3AuXG4gIC8vXG4gIC8vIHN0aWNreVNjcm9sbCBpcyBwYXNzZWQgYXMgYSBET00gYXR0cmlidXRlICh2aWEgaW5rLWJveCBkaXJlY3RseSkgc28gaXQnc1xuICAvLyBhdmFpbGFibGUgb24gdGhlIGZpcnN0IHJlbmRlciDigJQgcmVmIGNhbGxiYWNrcyBmaXJlIGFmdGVyIHRoZSBpbml0aWFsXG4gIC8vIGNvbW1pdCwgd2hpY2ggaXMgdG9vIGxhdGUgZm9yIHRoZSBmaXJzdCBmcmFtZS5cbiAgcmV0dXJuIChcbiAgICA8aW5rLWJveFxuICAgICAgcmVmPXtlbCA9PiB7XG4gICAgICAgIGRvbVJlZi5jdXJyZW50ID0gZWxcbiAgICAgICAgaWYgKGVsKSBlbC5zY3JvbGxUb3AgPz89IDBcbiAgICAgIH19XG4gICAgICBzdHlsZT17e1xuICAgICAgICBmbGV4V3JhcDogJ25vd3JhcCcsXG4gICAgICAgIGZsZXhEaXJlY3Rpb246IHN0eWxlLmZsZXhEaXJlY3Rpb24gPz8gJ3JvdycsXG4gICAgICAgIGZsZXhHcm93OiBzdHlsZS5mbGV4R3JvdyA/PyAwLFxuICAgICAgICBmbGV4U2hyaW5rOiBzdHlsZS5mbGV4U2hyaW5rID8/IDEsXG4gICAgICAgIC4uLnN0eWxlLFxuICAgICAgICBvdmVyZmxvd1g6ICdzY3JvbGwnLFxuICAgICAgICBvdmVyZmxvd1k6ICdzY3JvbGwnLFxuICAgICAgfX1cbiAgICAgIHsuLi4oc3RpY2t5U2Nyb2xsID8geyBzdGlja3lTY3JvbGw6IHRydWUgfSA6IHt9KX1cbiAgICA+XG4gICAgICA8Qm94IGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIiBmbGV4R3Jvdz17MX0gZmxleFNocmluaz17MH0gd2lkdGg9XCIxMDAlXCI+XG4gICAgICAgIHtjaGlsZHJlbn1cbiAgICAgIDwvQm94PlxuICAgIDwvaW5rLWJveD5cbiAgKVxufVxuXG5leHBvcnQgZGVmYXVsdCBTY3JvbGxCb3hcbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBT0EsS0FBSyxJQUNWLEtBQUtDLGlCQUFpQixFQUN0QixLQUFLQyxHQUFHLEVBQ1JDLG1CQUFtQixFQUNuQkMsTUFBTSxFQUNOQyxRQUFRLFFBQ0gsT0FBTztBQUNkLGNBQWNDLE1BQU0sUUFBUSxXQUFXO0FBQ3ZDLFNBQVNDLGtCQUFrQixRQUFRLDBCQUEwQjtBQUM3RCxjQUFjQyxVQUFVLFFBQVEsV0FBVztBQUMzQyxTQUFTQyxTQUFTLEVBQUVDLGtCQUFrQixRQUFRLFdBQVc7QUFDekQsU0FBU0MsZUFBZSxRQUFRLGtCQUFrQjtBQUNsRCxjQUFjQyxNQUFNLFFBQVEsY0FBYztBQUMxQyxPQUFPLGdCQUFnQjtBQUN2QixPQUFPQyxHQUFHLE1BQU0sVUFBVTtBQUUxQixPQUFPLEtBQUtDLGVBQWUsR0FBRztFQUM1QkMsUUFBUSxFQUFFLENBQUNDLENBQUMsRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJO0VBQzdCQyxRQUFRLEVBQUUsQ0FBQ0MsRUFBRSxFQUFFLE1BQU0sRUFBRSxHQUFHLElBQUk7RUFDOUI7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsZUFBZSxFQUFFLENBQUNDLEVBQUUsRUFBRVosVUFBVSxFQUFFYSxNQUFlLENBQVIsRUFBRSxNQUFNLEVBQUUsR0FBRyxJQUFJO0VBQzFEQyxjQUFjLEVBQUUsR0FBRyxHQUFHLElBQUk7RUFDMUJDLFlBQVksRUFBRSxHQUFHLEdBQUcsTUFBTTtFQUMxQkMsZUFBZSxFQUFFLEdBQUcsR0FBRyxNQUFNO0VBQzdCQyxlQUFlLEVBQUUsR0FBRyxHQUFHLE1BQU07RUFDN0I7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VDLG9CQUFvQixFQUFFLEdBQUcsR0FBRyxNQUFNO0VBQ2xDQyxpQkFBaUIsRUFBRSxHQUFHLEdBQUcsTUFBTTtFQUMvQjtBQUNGO0FBQ0E7QUFDQTtFQUNFQyxjQUFjLEVBQUUsR0FBRyxHQUFHLE1BQU07RUFDNUI7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRUMsUUFBUSxFQUFFLEdBQUcsR0FBRyxPQUFPO0VBQ3ZCO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFQyxTQUFTLEVBQUUsQ0FBQ0MsUUFBUSxFQUFFLEdBQUcsR0FBRyxJQUFJLEVBQUUsR0FBRyxHQUFHLEdBQUcsSUFBSTtFQUMvQztBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VDLGNBQWMsRUFBRSxDQUFDQyxHQUFHLEVBQUUsTUFBTSxHQUFHLFNBQVMsRUFBRUMsR0FBRyxFQUFFLE1BQU0sR0FBRyxTQUFTLEVBQUUsR0FBRyxJQUFJO0FBQzVFLENBQUM7QUFFRCxPQUFPLEtBQUtDLGNBQWMsR0FBRzdCLE1BQU0sQ0FDakNNLE1BQU0sRUFDTixVQUFVLEdBQUcsVUFBVSxHQUFHLFdBQVcsR0FBRyxXQUFXLENBQ3BELEdBQUc7RUFDRndCLEdBQUcsQ0FBQyxFQUFFbEMsR0FBRyxDQUFDWSxlQUFlLENBQUM7RUFDMUI7QUFDRjtBQUNBO0FBQ0E7RUFDRXVCLFlBQVksQ0FBQyxFQUFFLE9BQU87QUFDeEIsQ0FBQzs7QUFFRDtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBLFNBQVNDLFNBQVNBLENBQUM7RUFDakJDLFFBQVE7RUFDUkgsR0FBRztFQUNIQyxZQUFZO0VBQ1osR0FBR0c7QUFDOEIsQ0FBbEMsRUFBRXZDLGlCQUFpQixDQUFDa0MsY0FBYyxDQUFDLENBQUMsRUFBRW5DLEtBQUssQ0FBQ3lDLFNBQVMsQ0FBQztFQUNyRCxNQUFNQyxNQUFNLEdBQUd0QyxNQUFNLENBQUNJLFVBQVUsQ0FBQyxDQUFDLElBQUksQ0FBQztFQUN2QztFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0EsTUFBTSxHQUFHbUMsV0FBVyxDQUFDLEdBQUd0QyxRQUFRLENBQUMsQ0FBQyxDQUFDO0VBQ25DLE1BQU11QyxZQUFZLEdBQUd4QyxNQUFNLENBQUMsSUFBSXlDLEdBQUcsQ0FBQyxHQUFHLEdBQUcsSUFBSSxDQUFDLENBQUMsQ0FBQyxDQUFDO0VBQ2xELE1BQU1DLGVBQWUsR0FBRzFDLE1BQU0sQ0FBQyxLQUFLLENBQUM7RUFFckMsTUFBTTJDLE1BQU0sR0FBR0EsQ0FBQSxLQUFNO0lBQ25CLEtBQUssTUFBTUMsQ0FBQyxJQUFJSixZQUFZLENBQUNLLE9BQU8sRUFBRUQsQ0FBQyxDQUFDLENBQUM7RUFDM0MsQ0FBQztFQUVELFNBQVNFLGFBQWFBLENBQUM5QixFQUFFLEVBQUVaLFVBQVUsQ0FBQyxFQUFFLElBQUksQ0FBQztJQUMzQztJQUNBO0lBQ0E7SUFDQUQsa0JBQWtCLENBQUMsQ0FBQztJQUNwQkUsU0FBUyxDQUFDVyxFQUFFLENBQUM7SUFDYlQsZUFBZSxDQUFDLENBQUM7SUFDakJvQyxNQUFNLENBQUMsQ0FBQztJQUNSLElBQUlELGVBQWUsQ0FBQ0csT0FBTyxFQUFFO0lBQzdCSCxlQUFlLENBQUNHLE9BQU8sR0FBRyxJQUFJO0lBQzlCRSxjQUFjLENBQUMsTUFBTTtNQUNuQkwsZUFBZSxDQUFDRyxPQUFPLEdBQUcsS0FBSztNQUMvQnZDLGtCQUFrQixDQUFDVSxFQUFFLENBQUM7SUFDeEIsQ0FBQyxDQUFDO0VBQ0o7RUFFQWpCLG1CQUFtQixDQUNqQmlDLEdBQUcsRUFDSCxFQUFFLEVBQUV0QixlQUFlLEtBQUs7SUFDdEJDLFFBQVFBLENBQUNDLENBQUMsRUFBRSxNQUFNLEVBQUU7TUFDbEIsTUFBTUksRUFBRSxHQUFHc0IsTUFBTSxDQUFDTyxPQUFPO01BQ3pCLElBQUksQ0FBQzdCLEVBQUUsRUFBRTtNQUNUO01BQ0E7TUFDQUEsRUFBRSxDQUFDaUIsWUFBWSxHQUFHLEtBQUs7TUFDdkJqQixFQUFFLENBQUNnQyxrQkFBa0IsR0FBR0MsU0FBUztNQUNqQ2pDLEVBQUUsQ0FBQ2tDLFlBQVksR0FBR0QsU0FBUztNQUMzQmpDLEVBQUUsQ0FBQ21DLFNBQVMsR0FBR0MsSUFBSSxDQUFDdEIsR0FBRyxDQUFDLENBQUMsRUFBRXNCLElBQUksQ0FBQ0MsS0FBSyxDQUFDekMsQ0FBQyxDQUFDLENBQUM7TUFDekNrQyxhQUFhLENBQUM5QixFQUFFLENBQUM7SUFDbkIsQ0FBQztJQUNERCxlQUFlQSxDQUFDQyxFQUFFLEVBQUVaLFVBQVUsRUFBRWEsTUFBTSxHQUFHLENBQUMsRUFBRTtNQUMxQyxNQUFNcUMsR0FBRyxHQUFHaEIsTUFBTSxDQUFDTyxPQUFPO01BQzFCLElBQUksQ0FBQ1MsR0FBRyxFQUFFO01BQ1ZBLEdBQUcsQ0FBQ3JCLFlBQVksR0FBRyxLQUFLO01BQ3hCcUIsR0FBRyxDQUFDTixrQkFBa0IsR0FBR0MsU0FBUztNQUNsQ0ssR0FBRyxDQUFDSixZQUFZLEdBQUc7UUFBRWxDLEVBQUU7UUFBRUM7TUFBTyxDQUFDO01BQ2pDNkIsYUFBYSxDQUFDUSxHQUFHLENBQUM7SUFDcEIsQ0FBQztJQUNEekMsUUFBUUEsQ0FBQ0MsRUFBRSxFQUFFLE1BQU0sRUFBRTtNQUNuQixNQUFNRSxFQUFFLEdBQUdzQixNQUFNLENBQUNPLE9BQU87TUFDekIsSUFBSSxDQUFDN0IsRUFBRSxFQUFFO01BQ1RBLEVBQUUsQ0FBQ2lCLFlBQVksR0FBRyxLQUFLO01BQ3ZCO01BQ0FqQixFQUFFLENBQUNrQyxZQUFZLEdBQUdELFNBQVM7TUFDM0I7TUFDQTtNQUNBO01BQ0FqQyxFQUFFLENBQUNnQyxrQkFBa0IsR0FBRyxDQUFDaEMsRUFBRSxDQUFDZ0Msa0JBQWtCLElBQUksQ0FBQyxJQUFJSSxJQUFJLENBQUNDLEtBQUssQ0FBQ3ZDLEVBQUUsQ0FBQztNQUNyRWdDLGFBQWEsQ0FBQzlCLEVBQUUsQ0FBQztJQUNuQixDQUFDO0lBQ0RFLGNBQWNBLENBQUEsRUFBRztNQUNmLE1BQU1GLEVBQUUsR0FBR3NCLE1BQU0sQ0FBQ08sT0FBTztNQUN6QixJQUFJLENBQUM3QixFQUFFLEVBQUU7TUFDVEEsRUFBRSxDQUFDZ0Msa0JBQWtCLEdBQUdDLFNBQVM7TUFDakNqQyxFQUFFLENBQUNpQixZQUFZLEdBQUcsSUFBSTtNQUN0QjVCLFNBQVMsQ0FBQ1csRUFBRSxDQUFDO01BQ2IyQixNQUFNLENBQUMsQ0FBQztNQUNSSixXQUFXLENBQUNnQixDQUFDLElBQUlBLENBQUMsR0FBRyxDQUFDLENBQUM7SUFDekIsQ0FBQztJQUNEcEMsWUFBWUEsQ0FBQSxFQUFHO01BQ2IsT0FBT21CLE1BQU0sQ0FBQ08sT0FBTyxFQUFFTSxTQUFTLElBQUksQ0FBQztJQUN2QyxDQUFDO0lBQ0QvQixlQUFlQSxDQUFBLEVBQUc7TUFDaEI7TUFDQTtNQUNBO01BQ0EsT0FBT2tCLE1BQU0sQ0FBQ08sT0FBTyxFQUFFRyxrQkFBa0IsSUFBSSxDQUFDO0lBQ2hELENBQUM7SUFDRDNCLGVBQWVBLENBQUEsRUFBRztNQUNoQixPQUFPaUIsTUFBTSxDQUFDTyxPQUFPLEVBQUVXLFlBQVksSUFBSSxDQUFDO0lBQzFDLENBQUM7SUFDRGxDLG9CQUFvQkEsQ0FBQSxFQUFHO01BQ3JCLE1BQU1tQyxPQUFPLEdBQUduQixNQUFNLENBQUNPLE9BQU8sRUFBRWEsVUFBVSxDQUFDLENBQUMsQ0FBQyxJQUFJdEQsVUFBVSxHQUFHLFNBQVM7TUFDdkUsT0FDRXFELE9BQU8sRUFBRUUsUUFBUSxFQUFFQyxpQkFBaUIsQ0FBQyxDQUFDLElBQ3RDdEIsTUFBTSxDQUFDTyxPQUFPLEVBQUVXLFlBQVksSUFDNUIsQ0FBQztJQUVMLENBQUM7SUFDRGpDLGlCQUFpQkEsQ0FBQSxFQUFHO01BQ2xCLE9BQU9lLE1BQU0sQ0FBQ08sT0FBTyxFQUFFZ0Isb0JBQW9CLElBQUksQ0FBQztJQUNsRCxDQUFDO0lBQ0RyQyxjQUFjQSxDQUFBLEVBQUc7TUFDZixPQUFPYyxNQUFNLENBQUNPLE9BQU8sRUFBRWlCLGlCQUFpQixJQUFJLENBQUM7SUFDL0MsQ0FBQztJQUNEckMsUUFBUUEsQ0FBQSxFQUFHO01BQ1QsTUFBTVQsRUFBRSxHQUFHc0IsTUFBTSxDQUFDTyxPQUFPO01BQ3pCLElBQUksQ0FBQzdCLEVBQUUsRUFBRSxPQUFPLEtBQUs7TUFDckIsT0FBT0EsRUFBRSxDQUFDaUIsWUFBWSxJQUFJOEIsT0FBTyxDQUFDL0MsRUFBRSxDQUFDZ0QsVUFBVSxDQUFDLGNBQWMsQ0FBQyxDQUFDO0lBQ2xFLENBQUM7SUFDRHRDLFNBQVNBLENBQUNDLFFBQVEsRUFBRSxHQUFHLEdBQUcsSUFBSSxFQUFFO01BQzlCYSxZQUFZLENBQUNLLE9BQU8sQ0FBQ29CLEdBQUcsQ0FBQ3RDLFFBQVEsQ0FBQztNQUNsQyxPQUFPLE1BQU1hLFlBQVksQ0FBQ0ssT0FBTyxDQUFDcUIsTUFBTSxDQUFDdkMsUUFBUSxDQUFDO0lBQ3BELENBQUM7SUFDREMsY0FBY0EsQ0FBQ0MsR0FBRyxFQUFFQyxHQUFHLEVBQUU7TUFDdkIsTUFBTWQsRUFBRSxHQUFHc0IsTUFBTSxDQUFDTyxPQUFPO01BQ3pCLElBQUksQ0FBQzdCLEVBQUUsRUFBRTtNQUNUQSxFQUFFLENBQUNtRCxjQUFjLEdBQUd0QyxHQUFHO01BQ3ZCYixFQUFFLENBQUNvRCxjQUFjLEdBQUd0QyxHQUFHO0lBQ3pCO0VBQ0YsQ0FBQyxDQUFDO0VBQ0Y7RUFDQTtFQUNBO0VBQ0E7RUFDQSxFQUNGLENBQUM7O0VBRUQ7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBLE9BQ0UsQ0FBQyxPQUFPLENBQ04sR0FBRyxDQUFDLENBQUNkLEVBQUUsSUFBSTtJQUNUc0IsTUFBTSxDQUFDTyxPQUFPLEdBQUc3QixFQUFFO0lBQ25CLElBQUlBLEVBQUUsRUFBRUEsRUFBRSxDQUFDbUMsU0FBUyxLQUFLLENBQUM7RUFDNUIsQ0FBQyxDQUFDLENBQ0YsS0FBSyxDQUFDLENBQUM7SUFDTGtCLFFBQVEsRUFBRSxRQUFRO0lBQ2xCQyxhQUFhLEVBQUVsQyxLQUFLLENBQUNrQyxhQUFhLElBQUksS0FBSztJQUMzQ0MsUUFBUSxFQUFFbkMsS0FBSyxDQUFDbUMsUUFBUSxJQUFJLENBQUM7SUFDN0JDLFVBQVUsRUFBRXBDLEtBQUssQ0FBQ29DLFVBQVUsSUFBSSxDQUFDO0lBQ2pDLEdBQUdwQyxLQUFLO0lBQ1JxQyxTQUFTLEVBQUUsUUFBUTtJQUNuQkMsU0FBUyxFQUFFO0VBQ2IsQ0FBQyxDQUFDLENBQ0YsSUFBS3pDLFlBQVksR0FBRztJQUFFQSxZQUFZLEVBQUU7RUFBSyxDQUFDLEdBQUcsQ0FBQyxDQUFFLENBQUM7QUFFdkQsTUFBTSxDQUFDLEdBQUcsQ0FBQyxhQUFhLENBQUMsUUFBUSxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLFVBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxNQUFNO0FBQzFFLFFBQVEsQ0FBQ0UsUUFBUTtBQUNqQixNQUFNLEVBQUUsR0FBRztBQUNYLElBQUksRUFBRSxPQUFPLENBQUM7QUFFZDtBQUVBLGVBQWVELFNBQVMiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx new file mode 100644 index 0000000000..3ed7609b84 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/Spacer.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { c as _c } from 'react/compiler-runtime' + +import Box from './Box.js' + +/** + * A flexible space that expands along the major axis of its containing layout. + * It's useful as a shortcut for filling all the available spaces between elements. + */ +export default function Spacer() { + const $ = _c(1) + let t0 + + if ($[0] === Symbol.for('react.memo_cache_sentinel')) { + t0 = + $[0] = t0 + } else { + t0 = $[0] + } + + return t0 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlNwYWNlciIsIiQiLCJfYyIsInQwIiwiU3ltYm9sIiwiZm9yIl0sInNvdXJjZXMiOlsiU3BhY2VyLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgQm94IGZyb20gJy4vQm94LmpzJ1xuXG4vKipcbiAqIEEgZmxleGlibGUgc3BhY2UgdGhhdCBleHBhbmRzIGFsb25nIHRoZSBtYWpvciBheGlzIG9mIGl0cyBjb250YWluaW5nIGxheW91dC5cbiAqIEl0J3MgdXNlZnVsIGFzIGEgc2hvcnRjdXQgZm9yIGZpbGxpbmcgYWxsIHRoZSBhdmFpbGFibGUgc3BhY2VzIGJldHdlZW4gZWxlbWVudHMuXG4gKi9cbmV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIFNwYWNlcigpIHtcbiAgcmV0dXJuIDxCb3ggZmxleEdyb3c9ezF9IC8+XG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLE1BQU0sT0FBTztBQUN6QixPQUFPQyxHQUFHLE1BQU0sVUFBVTs7QUFFMUI7QUFDQTtBQUNBO0FBQ0E7QUFDQSxlQUFlLFNBQUFDLE9BQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUYsQ0FBQSxRQUFBRyxNQUFBLENBQUFDLEdBQUE7SUFDTkYsRUFBQSxJQUFDLEdBQUcsQ0FBVyxRQUFDLENBQUQsR0FBQyxHQUFJO0lBQUFGLENBQUEsTUFBQUUsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUYsQ0FBQTtFQUFBO0VBQUEsT0FBcEJFLEVBQW9CO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/StdinContext.ts b/ui-tui/packages/hermes-ink/src/ink/components/StdinContext.ts new file mode 100644 index 0000000000..c6e9334dfa --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/StdinContext.ts @@ -0,0 +1,25 @@ +import { createContext } from 'react' + +import { EventEmitter } from '../events/emitter.js' +import type { TerminalQuerier } from '../terminal-querier.js' + +export type Props = { + readonly stdin: NodeJS.ReadStream + readonly setRawMode: (value: boolean) => void + readonly isRawModeSupported: boolean + readonly exitOnCtrlC: boolean + readonly inputEmitter: EventEmitter + readonly querier: TerminalQuerier | null +} + +const StdinContext = createContext({ + stdin: process.stdin, + inputEmitter: new EventEmitter(), + setRawMode() {}, + isRawModeSupported: false, + exitOnCtrlC: true, + querier: null +}) + +StdinContext.displayName = 'StdinContext' +export default StdinContext diff --git a/ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx b/ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx new file mode 100644 index 0000000000..02860485a7 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, useSyncExternalStore } from 'react' +import { c as _c } from 'react/compiler-runtime' + +import { + getTerminalFocused, + getTerminalFocusState, + subscribeTerminalFocus, + type TerminalFocusState +} from '../terminal-focus-state.js' +export type { TerminalFocusState } +export type TerminalFocusContextProps = { + readonly isTerminalFocused: boolean + readonly terminalFocusState: TerminalFocusState +} + +const TerminalFocusContext = createContext({ + isTerminalFocused: true, + terminalFocusState: 'unknown' +}) + +TerminalFocusContext.displayName = 'TerminalFocusContext' + +// Separate component so App.tsx doesn't re-render on focus changes. +// Children are a stable prop reference, so they don't re-render either — +// only components that consume the context will re-render. +export function TerminalFocusProvider(t0) { + const $ = _c(6) + + const { children } = t0 + + const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused) + const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState) + let t1 + + if ($[0] !== isTerminalFocused || $[1] !== terminalFocusState) { + t1 = { + isTerminalFocused, + terminalFocusState + } + $[0] = isTerminalFocused + $[1] = terminalFocusState + $[2] = t1 + } else { + t1 = $[2] + } + + const value = t1 + let t2 + + if ($[3] !== children || $[4] !== value) { + t2 = {children} + $[3] = children + $[4] = value + $[5] = t2 + } else { + t2 = $[5] + } + + return t2 +} + +export default TerminalFocusContext +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsImNyZWF0ZUNvbnRleHQiLCJ1c2VNZW1vIiwidXNlU3luY0V4dGVybmFsU3RvcmUiLCJnZXRUZXJtaW5hbEZvY3VzZWQiLCJnZXRUZXJtaW5hbEZvY3VzU3RhdGUiLCJzdWJzY3JpYmVUZXJtaW5hbEZvY3VzIiwiVGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHRQcm9wcyIsImlzVGVybWluYWxGb2N1c2VkIiwidGVybWluYWxGb2N1c1N0YXRlIiwiVGVybWluYWxGb2N1c0NvbnRleHQiLCJkaXNwbGF5TmFtZSIsIlRlcm1pbmFsRm9jdXNQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInZhbHVlIiwidDIiXSwic291cmNlcyI6WyJUZXJtaW5hbEZvY3VzQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IFJlYWN0LCB7IGNyZWF0ZUNvbnRleHQsIHVzZU1lbW8sIHVzZVN5bmNFeHRlcm5hbFN0b3JlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQge1xuICBnZXRUZXJtaW5hbEZvY3VzZWQsXG4gIGdldFRlcm1pbmFsRm9jdXNTdGF0ZSxcbiAgc3Vic2NyaWJlVGVybWluYWxGb2N1cyxcbiAgdHlwZSBUZXJtaW5hbEZvY3VzU3RhdGUsXG59IGZyb20gJy4uL3Rlcm1pbmFsLWZvY3VzLXN0YXRlLmpzJ1xuXG5leHBvcnQgdHlwZSB7IFRlcm1pbmFsRm9jdXNTdGF0ZSB9XG5cbmV4cG9ydCB0eXBlIFRlcm1pbmFsRm9jdXNDb250ZXh0UHJvcHMgPSB7XG4gIHJlYWRvbmx5IGlzVGVybWluYWxGb2N1c2VkOiBib29sZWFuXG4gIHJlYWRvbmx5IHRlcm1pbmFsRm9jdXNTdGF0ZTogVGVybWluYWxGb2N1c1N0YXRlXG59XG5cbmNvbnN0IFRlcm1pbmFsRm9jdXNDb250ZXh0ID0gY3JlYXRlQ29udGV4dDxUZXJtaW5hbEZvY3VzQ29udGV4dFByb3BzPih7XG4gIGlzVGVybWluYWxGb2N1c2VkOiB0cnVlLFxuICB0ZXJtaW5hbEZvY3VzU3RhdGU6ICd1bmtub3duJyxcbn0pXG5cbi8vIGVzbGludC1kaXNhYmxlLW5leHQtbGluZSBjdXN0b20tcnVsZXMvbm8tdG9wLWxldmVsLXNpZGUtZWZmZWN0c1xuVGVybWluYWxGb2N1c0NvbnRleHQuZGlzcGxheU5hbWUgPSAnVGVybWluYWxGb2N1c0NvbnRleHQnXG5cbi8vIFNlcGFyYXRlIGNvbXBvbmVudCBzbyBBcHAudHN4IGRvZXNuJ3QgcmUtcmVuZGVyIG9uIGZvY3VzIGNoYW5nZXMuXG4vLyBDaGlsZHJlbiBhcmUgYSBzdGFibGUgcHJvcCByZWZlcmVuY2UsIHNvIHRoZXkgZG9uJ3QgcmUtcmVuZGVyIGVpdGhlciDigJRcbi8vIG9ubHkgY29tcG9uZW50cyB0aGF0IGNvbnN1bWUgdGhlIGNvbnRleHQgd2lsbCByZS1yZW5kZXIuXG5leHBvcnQgZnVuY3Rpb24gVGVybWluYWxGb2N1c1Byb3ZpZGVyKHtcbiAgY2hpbGRyZW4sXG59OiB7XG4gIGNoaWxkcmVuOiBSZWFjdC5SZWFjdE5vZGVcbn0pOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCBpc1Rlcm1pbmFsRm9jdXNlZCA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c2VkLFxuICApXG4gIGNvbnN0IHRlcm1pbmFsRm9jdXNTdGF0ZSA9IHVzZVN5bmNFeHRlcm5hbFN0b3JlKFxuICAgIHN1YnNjcmliZVRlcm1pbmFsRm9jdXMsXG4gICAgZ2V0VGVybWluYWxGb2N1c1N0YXRlLFxuICApXG5cbiAgY29uc3QgdmFsdWUgPSB1c2VNZW1vKFxuICAgICgpID0+ICh7IGlzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGUgfSksXG4gICAgW2lzVGVybWluYWxGb2N1c2VkLCB0ZXJtaW5hbEZvY3VzU3RhdGVdLFxuICApXG5cbiAgcmV0dXJuIChcbiAgICA8VGVybWluYWxGb2N1c0NvbnRleHQuUHJvdmlkZXIgdmFsdWU9e3ZhbHVlfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L1Rlcm1pbmFsRm9jdXNDb250ZXh0LlByb3ZpZGVyPlxuICApXG59XG5cbmV4cG9ydCBkZWZhdWx0IFRlcm1pbmFsRm9jdXNDb250ZXh0XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxPQUFPQSxLQUFLLElBQUlDLGFBQWEsRUFBRUMsT0FBTyxFQUFFQyxvQkFBb0IsUUFBUSxPQUFPO0FBQzNFLFNBQ0VDLGtCQUFrQixFQUNsQkMscUJBQXFCLEVBQ3JCQyxzQkFBc0IsRUFDdEIsS0FBS0Msa0JBQWtCLFFBQ2xCLDRCQUE0QjtBQUVuQyxjQUFjQSxrQkFBa0I7QUFFaEMsT0FBTyxLQUFLQyx5QkFBeUIsR0FBRztFQUN0QyxTQUFTQyxpQkFBaUIsRUFBRSxPQUFPO0VBQ25DLFNBQVNDLGtCQUFrQixFQUFFSCxrQkFBa0I7QUFDakQsQ0FBQztBQUVELE1BQU1JLG9CQUFvQixHQUFHVixhQUFhLENBQUNPLHlCQUF5QixDQUFDLENBQUM7RUFDcEVDLGlCQUFpQixFQUFFLElBQUk7RUFDdkJDLGtCQUFrQixFQUFFO0FBQ3RCLENBQUMsQ0FBQzs7QUFFRjtBQUNBQyxvQkFBb0IsQ0FBQ0MsV0FBVyxHQUFHLHNCQUFzQjs7QUFFekQ7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxzQkFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUErQjtJQUFBQztFQUFBLElBQUFILEVBSXJDO0VBQ0MsTUFBQUwsaUJBQUEsR0FBMEJOLG9CQUFvQixDQUM1Q0csc0JBQXNCLEVBQ3RCRixrQkFDRixDQUFDO0VBQ0QsTUFBQU0sa0JBQUEsR0FBMkJQLG9CQUFvQixDQUM3Q0csc0JBQXNCLEVBQ3RCRCxxQkFDRixDQUFDO0VBQUEsSUFBQWEsRUFBQTtFQUFBLElBQUFILENBQUEsUUFBQU4saUJBQUEsSUFBQU0sQ0FBQSxRQUFBTCxrQkFBQTtJQUdRUSxFQUFBO01BQUFULGlCQUFBO01BQUFDO0lBQXdDLENBQUM7SUFBQUssQ0FBQSxNQUFBTixpQkFBQTtJQUFBTSxDQUFBLE1BQUFMLGtCQUFBO0lBQUFLLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBRGxELE1BQUFJLEtBQUEsR0FDU0QsRUFBeUM7RUFFakQsSUFBQUUsRUFBQTtFQUFBLElBQUFMLENBQUEsUUFBQUUsUUFBQSxJQUFBRixDQUFBLFFBQUFJLEtBQUE7SUFHQ0MsRUFBQSxrQ0FBc0NELEtBQUssQ0FBTEEsTUFBSSxDQUFDLENBQ3hDRixTQUFPLENBQ1YsZ0NBQWdDO0lBQUFGLENBQUEsTUFBQUUsUUFBQTtJQUFBRixDQUFBLE1BQUFJLEtBQUE7SUFBQUosQ0FBQSxNQUFBSyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBTCxDQUFBO0VBQUE7RUFBQSxPQUZoQ0ssRUFFZ0M7QUFBQTtBQUlwQyxlQUFlVCxvQkFBb0IiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/TerminalSizeContext.tsx b/ui-tui/packages/hermes-ink/src/ink/components/TerminalSizeContext.tsx new file mode 100644 index 0000000000..ec743b3a0e --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/TerminalSizeContext.tsx @@ -0,0 +1,7 @@ +import { createContext } from 'react' +export type TerminalSize = { + columns: number + rows: number +} +export const TerminalSizeContext = createContext(null) +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJjcmVhdGVDb250ZXh0IiwiVGVybWluYWxTaXplIiwiY29sdW1ucyIsInJvd3MiLCJUZXJtaW5hbFNpemVDb250ZXh0Il0sInNvdXJjZXMiOlsiVGVybWluYWxTaXplQ29udGV4dC50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgY3JlYXRlQ29udGV4dCB9IGZyb20gJ3JlYWN0J1xuXG5leHBvcnQgdHlwZSBUZXJtaW5hbFNpemUgPSB7XG4gIGNvbHVtbnM6IG51bWJlclxuICByb3dzOiBudW1iZXJcbn1cblxuZXhwb3J0IGNvbnN0IFRlcm1pbmFsU2l6ZUNvbnRleHQgPSBjcmVhdGVDb250ZXh0PFRlcm1pbmFsU2l6ZSB8IG51bGw+KG51bGwpXG4iXSwibWFwcGluZ3MiOiJBQUFBLFNBQVNBLGFBQWEsUUFBUSxPQUFPO0FBRXJDLE9BQU8sS0FBS0MsWUFBWSxHQUFHO0VBQ3pCQyxPQUFPLEVBQUUsTUFBTTtFQUNmQyxJQUFJLEVBQUUsTUFBTTtBQUNkLENBQUM7QUFFRCxPQUFPLE1BQU1DLG1CQUFtQixHQUFHSixhQUFhLENBQUNDLFlBQVksR0FBRyxJQUFJLENBQUMsQ0FBQyxJQUFJLENBQUMiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx new file mode 100644 index 0000000000..f69d338c1f --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -0,0 +1,296 @@ +import type { ReactNode } from 'react' +import React from 'react' +import { c as _c } from 'react/compiler-runtime' + +import type { Color, Styles } from '../styles.js' +type BaseProps = { + /** + * Change text color. Accepts a raw color value (rgb, hex, ansi). + */ + readonly color?: Color + + /** + * Same as `color`, but for background. + */ + readonly backgroundColor?: Color + + /** + * Make the text italic. + */ + readonly italic?: boolean + + /** + * Make the text underlined. + */ + readonly underline?: boolean + + /** + * Make the text crossed with a line. + */ + readonly strikethrough?: boolean + + /** + * Inverse background and foreground colors. + */ + readonly inverse?: boolean + + /** + * This property tells Ink to wrap or truncate text if its width is larger than container. + * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines. + * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off. + */ + readonly wrap?: Styles['textWrap'] + readonly children?: ReactNode +} + +/** + * Bold and dim are mutually exclusive in terminals. + * This type ensures you can use one or the other, but not both. + */ +type WeightProps = + | { + bold?: never + dim?: never + } + | { + bold: boolean + dim?: never + } + | { + dim: boolean + bold?: never + } +export type Props = BaseProps & WeightProps + +const memoizedStylesForWrap: Record, Styles> = { + wrap: { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'wrap' + }, + 'wrap-trim': { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'wrap-trim' + }, + end: { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'end' + }, + middle: { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'middle' + }, + 'truncate-end': { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'truncate-end' + }, + truncate: { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'truncate' + }, + 'truncate-middle': { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'truncate-middle' + }, + 'truncate-start': { + flexGrow: 0, + flexShrink: 1, + flexDirection: 'row', + textWrap: 'truncate-start' + } +} as const + +/** + * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough. + */ +export default function Text(t0) { + const $ = _c(29) + + const { + color, + backgroundColor, + bold, + dim, + italic: t1, + underline: t2, + strikethrough: t3, + inverse: t4, + wrap: t5, + children + } = t0 + + const italic = t1 === undefined ? false : t1 + const underline = t2 === undefined ? false : t2 + const strikethrough = t3 === undefined ? false : t3 + const inverse = t4 === undefined ? false : t4 + const wrap = t5 === undefined ? 'wrap' : t5 + + if (children === undefined || children === null) { + return null + } + + let t6 + + if ($[0] !== color) { + t6 = color && { + color + } + $[0] = color + $[1] = t6 + } else { + t6 = $[1] + } + + let t7 + + if ($[2] !== backgroundColor) { + t7 = backgroundColor && { + backgroundColor + } + $[2] = backgroundColor + $[3] = t7 + } else { + t7 = $[3] + } + + let t8 + + if ($[4] !== dim) { + t8 = dim && { + dim + } + $[4] = dim + $[5] = t8 + } else { + t8 = $[5] + } + + let t9 + + if ($[6] !== bold) { + t9 = bold && { + bold + } + $[6] = bold + $[7] = t9 + } else { + t9 = $[7] + } + + let t10 + + if ($[8] !== italic) { + t10 = italic && { + italic + } + $[8] = italic + $[9] = t10 + } else { + t10 = $[9] + } + + let t11 + + if ($[10] !== underline) { + t11 = underline && { + underline + } + $[10] = underline + $[11] = t11 + } else { + t11 = $[11] + } + + let t12 + + if ($[12] !== strikethrough) { + t12 = strikethrough && { + strikethrough + } + $[12] = strikethrough + $[13] = t12 + } else { + t12 = $[13] + } + + let t13 + + if ($[14] !== inverse) { + t13 = inverse && { + inverse + } + $[14] = inverse + $[15] = t13 + } else { + t13 = $[15] + } + + let t14 + + if ( + $[16] !== t10 || + $[17] !== t11 || + $[18] !== t12 || + $[19] !== t13 || + $[20] !== t6 || + $[21] !== t7 || + $[22] !== t8 || + $[23] !== t9 + ) { + t14 = { + ...t6, + ...t7, + ...t8, + ...t9, + ...t10, + ...t11, + ...t12, + ...t13 + } + $[16] = t10 + $[17] = t11 + $[18] = t12 + $[19] = t13 + $[20] = t6 + $[21] = t7 + $[22] = t8 + $[23] = t9 + $[24] = t14 + } else { + t14 = $[24] + } + + const textStyles = t14 + const t15 = memoizedStylesForWrap[wrap] + let t16 + + if ($[25] !== children || $[26] !== t15 || $[27] !== textStyles) { + t16 = ( + + {children} + + ) + $[25] = children + $[26] = t15 + $[27] = textStyles + $[28] = t16 + } else { + t16 = $[28] + } + + return t16 +} +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdE5vZGUiLCJSZWFjdCIsIkNvbG9yIiwiU3R5bGVzIiwiVGV4dFN0eWxlcyIsIkJhc2VQcm9wcyIsImNvbG9yIiwiYmFja2dyb3VuZENvbG9yIiwiaXRhbGljIiwidW5kZXJsaW5lIiwic3RyaWtldGhyb3VnaCIsImludmVyc2UiLCJ3cmFwIiwiY2hpbGRyZW4iLCJXZWlnaHRQcm9wcyIsImJvbGQiLCJkaW0iLCJQcm9wcyIsIm1lbW9pemVkU3R5bGVzRm9yV3JhcCIsIlJlY29yZCIsIk5vbk51bGxhYmxlIiwiZmxleEdyb3ciLCJmbGV4U2hyaW5rIiwiZmxleERpcmVjdGlvbiIsInRleHRXcmFwIiwiZW5kIiwibWlkZGxlIiwidHJ1bmNhdGUiLCJjb25zdCIsIlRleHQiLCJ0MCIsIiQiLCJfYyIsInQxIiwidDIiLCJ0MyIsInQ0IiwidDUiLCJ1bmRlZmluZWQiLCJ0NiIsInQ3IiwidDgiLCJ0OSIsInQxMCIsInQxMSIsInQxMiIsInQxMyIsInQxNCIsInRleHRTdHlsZXMiLCJ0MTUiLCJ0MTYiXSwic291cmNlcyI6WyJUZXh0LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgdHlwZSB7IFJlYWN0Tm9kZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IFJlYWN0IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBDb2xvciwgU3R5bGVzLCBUZXh0U3R5bGVzIH0gZnJvbSAnLi4vc3R5bGVzLmpzJ1xuXG50eXBlIEJhc2VQcm9wcyA9IHtcbiAgLyoqXG4gICAqIENoYW5nZSB0ZXh0IGNvbG9yLiBBY2NlcHRzIGEgcmF3IGNvbG9yIHZhbHVlIChyZ2IsIGhleCwgYW5zaSkuXG4gICAqL1xuICByZWFkb25seSBjb2xvcj86IENvbG9yXG5cbiAgLyoqXG4gICAqIFNhbWUgYXMgYGNvbG9yYCwgYnV0IGZvciBiYWNrZ3JvdW5kLlxuICAgKi9cbiAgcmVhZG9ubHkgYmFja2dyb3VuZENvbG9yPzogQ29sb3JcblxuICAvKipcbiAgICogTWFrZSB0aGUgdGV4dCBpdGFsaWMuXG4gICAqL1xuICByZWFkb25seSBpdGFsaWM/OiBib29sZWFuXG5cbiAgLyoqXG4gICAqIE1ha2UgdGhlIHRleHQgdW5kZXJsaW5lZC5cbiAgICovXG4gIHJlYWRvbmx5IHVuZGVybGluZT86IGJvb2xlYW5cblxuICAvKipcbiAgICogTWFrZSB0aGUgdGV4dCBjcm9zc2VkIHdpdGggYSBsaW5lLlxuICAgKi9cbiAgcmVhZG9ubHkgc3RyaWtldGhyb3VnaD86IGJvb2xlYW5cblxuICAvKipcbiAgICogSW52ZXJzZSBiYWNrZ3JvdW5kIGFuZCBmb3JlZ3JvdW5kIGNvbG9ycy5cbiAgICovXG4gIHJlYWRvbmx5IGludmVyc2U/OiBib29sZWFuXG5cbiAgLyoqXG4gICAqIFRoaXMgcHJvcGVydHkgdGVsbHMgSW5rIHRvIHdyYXAgb3IgdHJ1bmNhdGUgdGV4dCBpZiBpdHMgd2lkdGggaXMgbGFyZ2VyIHRoYW4gY29udGFpbmVyLlxuICAgKiBJZiBgd3JhcGAgaXMgcGFzc2VkIChieSBkZWZhdWx0KSwgSW5rIHdpbGwgd3JhcCB0ZXh0IGFuZCBzcGxpdCBpdCBpbnRvIG11bHRpcGxlIGxpbmVzLlxuICAgKiBJZiBgdHJ1bmNhdGUtKmAgaXMgcGFzc2VkLCBJbmsgd2lsbCB0cnVuY2F0ZSB0ZXh0IGluc3RlYWQsIHdoaWNoIHdpbGwgcmVzdWx0IGluIG9uZSBsaW5lIG9mIHRleHQgd2l0aCB0aGUgcmVzdCBjdXQgb2ZmLlxuICAgKi9cbiAgcmVhZG9ubHkgd3JhcD86IFN0eWxlc1sndGV4dFdyYXAnXVxuXG4gIHJlYWRvbmx5IGNoaWxkcmVuPzogUmVhY3ROb2RlXG59XG5cbi8qKlxuICogQm9sZCBhbmQgZGltIGFyZSBtdXR1YWxseSBleGNsdXNpdmUgaW4gdGVybWluYWxzLlxuICogVGhpcyB0eXBlIGVuc3VyZXMgeW91IGNhbiB1c2Ugb25lIG9yIHRoZSBvdGhlciwgYnV0IG5vdCBib3RoLlxuICovXG50eXBlIFdlaWdodFByb3BzID1cbiAgfCB7IGJvbGQ/OiBuZXZlcjsgZGltPzogbmV2ZXIgfVxuICB8IHsgYm9sZDogYm9vbGVhbjsgZGltPzogbmV2ZXIgfVxuICB8IHsgZGltOiBib29sZWFuOyBib2xkPzogbmV2ZXIgfVxuXG5leHBvcnQgdHlwZSBQcm9wcyA9IEJhc2VQcm9wcyAmIFdlaWdodFByb3BzXG5cbmNvbnN0IG1lbW9pemVkU3R5bGVzRm9yV3JhcDogUmVjb3JkPE5vbk51bGxhYmxlPFN0eWxlc1sndGV4dFdyYXAnXT4sIFN0eWxlcz4gPSB7XG4gIHdyYXA6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAnd3JhcCcsXG4gIH0sXG4gICd3cmFwLXRyaW0nOiB7XG4gICAgZmxleEdyb3c6IDAsXG4gICAgZmxleFNocmluazogMSxcbiAgICBmbGV4RGlyZWN0aW9uOiAncm93JyxcbiAgICB0ZXh0V3JhcDogJ3dyYXAtdHJpbScsXG4gIH0sXG4gIGVuZDoge1xuICAgIGZsZXhHcm93OiAwLFxuICAgIGZsZXhTaHJpbms6IDEsXG4gICAgZmxleERpcmVjdGlvbjogJ3JvdycsXG4gICAgdGV4dFdyYXA6ICdlbmQnLFxuICB9LFxuICBtaWRkbGU6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAnbWlkZGxlJyxcbiAgfSxcbiAgJ3RydW5jYXRlLWVuZCc6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAndHJ1bmNhdGUtZW5kJyxcbiAgfSxcbiAgdHJ1bmNhdGU6IHtcbiAgICBmbGV4R3JvdzogMCxcbiAgICBmbGV4U2hyaW5rOiAxLFxuICAgIGZsZXhEaXJlY3Rpb246ICdyb3cnLFxuICAgIHRleHRXcmFwOiAndHJ1bmNhdGUnLFxuICB9LFxuICAndHJ1bmNhdGUtbWlkZGxlJzoge1xuICAgIGZsZXhHcm93OiAwLFxuICAgIGZsZXhTaHJpbms6IDEsXG4gICAgZmxleERpcmVjdGlvbjogJ3JvdycsXG4gICAgdGV4dFdyYXA6ICd0cnVuY2F0ZS1taWRkbGUnLFxuICB9LFxuICAndHJ1bmNhdGUtc3RhcnQnOiB7XG4gICAgZmxleEdyb3c6IDAsXG4gICAgZmxleFNocmluazogMSxcbiAgICBmbGV4RGlyZWN0aW9uOiAncm93JyxcbiAgICB0ZXh0V3JhcDogJ3RydW5jYXRlLXN0YXJ0JyxcbiAgfSxcbn0gYXMgY29uc3RcblxuLyoqXG4gKiBUaGlzIGNvbXBvbmVudCBjYW4gZGlzcGxheSB0ZXh0LCBhbmQgY2hhbmdlIGl0cyBzdHlsZSB0byBtYWtlIGl0IGNvbG9yZnVsLCBib2xkLCB1bmRlcmxpbmUsIGl0YWxpYyBvciBzdHJpa2V0aHJvdWdoLlxuICovXG5leHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBUZXh0KHtcbiAgY29sb3IsXG4gIGJhY2tncm91bmRDb2xvcixcbiAgYm9sZCxcbiAgZGltLFxuICBpdGFsaWMgPSBmYWxzZSxcbiAgdW5kZXJsaW5lID0gZmFsc2UsXG4gIHN0cmlrZXRocm91Z2ggPSBmYWxzZSxcbiAgaW52ZXJzZSA9IGZhbHNlLFxuICB3cmFwID0gJ3dyYXAnLFxuICBjaGlsZHJlbixcbn06IFByb3BzKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgaWYgKGNoaWxkcmVuID09PSB1bmRlZmluZWQgfHwgY2hpbGRyZW4gPT09IG51bGwpIHtcbiAgICByZXR1cm4gbnVsbFxuICB9XG5cbiAgLy8gQnVpbGQgdGV4dFN0eWxlcyBvYmplY3Qgd2l0aCBvbmx5IHRoZSBwcm9wZXJ0aWVzIHRoYXQgYXJlIHNldFxuICBjb25zdCB0ZXh0U3R5bGVzOiBUZXh0U3R5bGVzID0ge1xuICAgIC4uLihjb2xvciAmJiB7IGNvbG9yIH0pLFxuICAgIC4uLihiYWNrZ3JvdW5kQ29sb3IgJiYgeyBiYWNrZ3JvdW5kQ29sb3IgfSksXG4gICAgLi4uKGRpbSAmJiB7IGRpbSB9KSxcbiAgICAuLi4oYm9sZCAmJiB7IGJvbGQgfSksXG4gICAgLi4uKGl0YWxpYyAmJiB7IGl0YWxpYyB9KSxcbiAgICAuLi4odW5kZXJsaW5lICYmIHsgdW5kZXJsaW5lIH0pLFxuICAgIC4uLihzdHJpa2V0aHJvdWdoICYmIHsgc3RyaWtldGhyb3VnaCB9KSxcbiAgICAuLi4oaW52ZXJzZSAmJiB7IGludmVyc2UgfSksXG4gIH1cblxuICByZXR1cm4gKFxuICAgIDxpbmstdGV4dCBzdHlsZT17bWVtb2l6ZWRTdHlsZXNGb3JXcmFwW3dyYXBdfSB0ZXh0U3R5bGVzPXt0ZXh0U3R5bGVzfT5cbiAgICAgIHtjaGlsZHJlbn1cbiAgICA8L2luay10ZXh0PlxuICApXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxjQUFjQSxTQUFTLFFBQVEsT0FBTztBQUN0QyxPQUFPQyxLQUFLLE1BQU0sT0FBTztBQUN6QixjQUFjQyxLQUFLLEVBQUVDLE1BQU0sRUFBRUMsVUFBVSxRQUFRLGNBQWM7QUFFN0QsS0FBS0MsU0FBUyxHQUFHO0VBQ2Y7QUFDRjtBQUNBO0VBQ0UsU0FBU0MsS0FBSyxDQUFDLEVBQUVKLEtBQUs7O0VBRXRCO0FBQ0Y7QUFDQTtFQUNFLFNBQVNLLGVBQWUsQ0FBQyxFQUFFTCxLQUFLOztFQUVoQztBQUNGO0FBQ0E7RUFDRSxTQUFTTSxNQUFNLENBQUMsRUFBRSxPQUFPOztFQUV6QjtBQUNGO0FBQ0E7RUFDRSxTQUFTQyxTQUFTLENBQUMsRUFBRSxPQUFPOztFQUU1QjtBQUNGO0FBQ0E7RUFDRSxTQUFTQyxhQUFhLENBQUMsRUFBRSxPQUFPOztFQUVoQztBQUNGO0FBQ0E7RUFDRSxTQUFTQyxPQUFPLENBQUMsRUFBRSxPQUFPOztFQUUxQjtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UsU0FBU0MsSUFBSSxDQUFDLEVBQUVULE1BQU0sQ0FBQyxVQUFVLENBQUM7RUFFbEMsU0FBU1UsUUFBUSxDQUFDLEVBQUViLFNBQVM7QUFDL0IsQ0FBQzs7QUFFRDtBQUNBO0FBQ0E7QUFDQTtBQUNBLEtBQUtjLFdBQVcsR0FDWjtFQUFFQyxJQUFJLENBQUMsRUFBRSxLQUFLO0VBQUVDLEdBQUcsQ0FBQyxFQUFFLEtBQUs7QUFBQyxDQUFDLEdBQzdCO0VBQUVELElBQUksRUFBRSxPQUFPO0VBQUVDLEdBQUcsQ0FBQyxFQUFFLEtBQUs7QUFBQyxDQUFDLEdBQzlCO0VBQUVBLEdBQUcsRUFBRSxPQUFPO0VBQUVELElBQUksQ0FBQyxFQUFFLEtBQUs7QUFBQyxDQUFDO0FBRWxDLE9BQU8sS0FBS0UsS0FBSyxHQUFHWixTQUFTLEdBQUdTLFdBQVc7QUFFM0MsTUFBTUkscUJBQXFCLEVBQUVDLE1BQU0sQ0FBQ0MsV0FBVyxDQUFDakIsTUFBTSxDQUFDLFVBQVUsQ0FBQyxDQUFDLEVBQUVBLE1BQU0sQ0FBQyxHQUFHO0VBQzdFUyxJQUFJLEVBQUU7SUFDSlMsUUFBUSxFQUFFLENBQUM7SUFDWEMsVUFBVSxFQUFFLENBQUM7SUFDYkMsYUFBYSxFQUFFLEtBQUs7SUFDcEJDLFFBQVEsRUFBRTtFQUNaLENBQUM7RUFDRCxXQUFXLEVBQUU7SUFDWEgsUUFBUSxFQUFFLENBQUM7SUFDWEMsVUFBVSxFQUFFLENBQUM7SUFDYkMsYUFBYSxFQUFFLEtBQUs7SUFDcEJDLFFBQVEsRUFBRTtFQUNaLENBQUM7RUFDREMsR0FBRyxFQUFFO0lBQ0hKLFFBQVEsRUFBRSxDQUFDO0lBQ1hDLFVBQVUsRUFBRSxDQUFDO0lBQ2JDLGFBQWEsRUFBRSxLQUFLO0lBQ3BCQyxRQUFRLEVBQUU7RUFDWixDQUFDO0VBQ0RFLE1BQU0sRUFBRTtJQUNOTCxRQUFRLEVBQUUsQ0FBQztJQUNYQyxVQUFVLEVBQUUsQ0FBQztJQUNiQyxhQUFhLEVBQUUsS0FBSztJQUNwQkMsUUFBUSxFQUFFO0VBQ1osQ0FBQztFQUNELGNBQWMsRUFBRTtJQUNkSCxRQUFRLEVBQUUsQ0FBQztJQUNYQyxVQUFVLEVBQUUsQ0FBQztJQUNiQyxhQUFhLEVBQUUsS0FBSztJQUNwQkMsUUFBUSxFQUFFO0VBQ1osQ0FBQztFQUNERyxRQUFRLEVBQUU7SUFDUk4sUUFBUSxFQUFFLENBQUM7SUFDWEMsVUFBVSxFQUFFLENBQUM7SUFDYkMsYUFBYSxFQUFFLEtBQUs7SUFDcEJDLFFBQVEsRUFBRTtFQUNaLENBQUM7RUFDRCxpQkFBaUIsRUFBRTtJQUNqQkgsUUFBUSxFQUFFLENBQUM7SUFDWEMsVUFBVSxFQUFFLENBQUM7SUFDYkMsYUFBYSxFQUFFLEtBQUs7SUFDcEJDLFFBQVEsRUFBRTtFQUNaLENBQUM7RUFDRCxnQkFBZ0IsRUFBRTtJQUNoQkgsUUFBUSxFQUFFLENBQUM7SUFDWEMsVUFBVSxFQUFFLENBQUM7SUFDYkMsYUFBYSxFQUFFLEtBQUs7SUFDcEJDLFFBQVEsRUFBRTtFQUNaO0FBQ0YsQ0FBQyxJQUFJSSxLQUFLOztBQUVWO0FBQ0E7QUFDQTtBQUNBLGVBQWUsU0FBQUMsS0FBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFjO0lBQUExQixLQUFBO0lBQUFDLGVBQUE7SUFBQVEsSUFBQTtJQUFBQyxHQUFBO0lBQUFSLE1BQUEsRUFBQXlCLEVBQUE7SUFBQXhCLFNBQUEsRUFBQXlCLEVBQUE7SUFBQXhCLGFBQUEsRUFBQXlCLEVBQUE7SUFBQXhCLE9BQUEsRUFBQXlCLEVBQUE7SUFBQXhCLElBQUEsRUFBQXlCLEVBQUE7SUFBQXhCO0VBQUEsSUFBQWlCLEVBV3JCO0VBTk4sTUFBQXRCLE1BQUEsR0FBQXlCLEVBQWMsS0FBZEssU0FBYyxHQUFkLEtBQWMsR0FBZEwsRUFBYztFQUNkLE1BQUF4QixTQUFBLEdBQUF5QixFQUFpQixLQUFqQkksU0FBaUIsR0FBakIsS0FBaUIsR0FBakJKLEVBQWlCO0VBQ2pCLE1BQUF4QixhQUFBLEdBQUF5QixFQUFxQixLQUFyQkcsU0FBcUIsR0FBckIsS0FBcUIsR0FBckJILEVBQXFCO0VBQ3JCLE1BQUF4QixPQUFBLEdBQUF5QixFQUFlLEtBQWZFLFNBQWUsR0FBZixLQUFlLEdBQWZGLEVBQWU7RUFDZixNQUFBeEIsSUFBQSxHQUFBeUIsRUFBYSxLQUFiQyxTQUFhLEdBQWIsTUFBYSxHQUFiRCxFQUFhO0VBR2IsSUFBSXhCLFFBQVEsS0FBS3lCLFNBQThCLElBQWpCekIsUUFBUSxLQUFLLElBQUk7SUFBQSxPQUN0QyxJQUFJO0VBQUE7RUFDWixJQUFBMEIsRUFBQTtFQUFBLElBQUFSLENBQUEsUUFBQXpCLEtBQUE7SUFJS2lDLEVBQUEsR0FBQWpDLEtBQWtCLElBQWxCO01BQUFBO0lBQWlCLENBQUM7SUFBQXlCLENBQUEsTUFBQXpCLEtBQUE7SUFBQXlCLENBQUEsTUFBQVEsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQVIsQ0FBQTtFQUFBO0VBQUEsSUFBQVMsRUFBQTtFQUFBLElBQUFULENBQUEsUUFBQXhCLGVBQUE7SUFDbEJpQyxFQUFBLEdBQUFqQyxlQUFzQyxJQUF0QztNQUFBQTtJQUFxQyxDQUFDO0lBQUF3QixDQUFBLE1BQUF4QixlQUFBO0lBQUF3QixDQUFBLE1BQUFTLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFULENBQUE7RUFBQTtFQUFBLElBQUFVLEVBQUE7RUFBQSxJQUFBVixDQUFBLFFBQUFmLEdBQUE7SUFDdEN5QixFQUFBLEdBQUF6QixHQUFjLElBQWQ7TUFBQUE7SUFBYSxDQUFDO0lBQUFlLENBQUEsTUFBQWYsR0FBQTtJQUFBZSxDQUFBLE1BQUFVLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFWLENBQUE7RUFBQTtFQUFBLElBQUFXLEVBQUE7RUFBQSxJQUFBWCxDQUFBLFFBQUFoQixJQUFBO0lBQ2QyQixFQUFBLEdBQUEzQixJQUFnQixJQUFoQjtNQUFBQTtJQUFlLENBQUM7SUFBQWdCLENBQUEsTUFBQWhCLElBQUE7SUFBQWdCLENBQUEsTUFBQVcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQVgsQ0FBQTtFQUFBO0VBQUEsSUFBQVksR0FBQTtFQUFBLElBQUFaLENBQUEsUUFBQXZCLE1BQUE7SUFDaEJtQyxHQUFBLEdBQUFuQyxNQUFvQixJQUFwQjtNQUFBQTtJQUFtQixDQUFDO0lBQUF1QixDQUFBLE1BQUF2QixNQUFBO0lBQUF1QixDQUFBLE1BQUFZLEdBQUE7RUFBQTtJQUFBQSxHQUFBLEdBQUFaLENBQUE7RUFBQTtFQUFBLElBQUFhLEdBQUE7RUFBQSxJQUFBYixDQUFBLFNBQUF0QixTQUFBO0lBQ3BCbUMsR0FBQSxHQUFBbkMsU0FBMEIsSUFBMUI7TUFBQUE7SUFBeUIsQ0FBQztJQUFBc0IsQ0FBQSxPQUFBdEIsU0FBQTtJQUFBc0IsQ0FBQSxPQUFBYSxHQUFBO0VBQUE7SUFBQUEsR0FBQSxHQUFBYixDQUFBO0VBQUE7RUFBQSxJQUFBYyxHQUFBO0VBQUEsSUFBQWQsQ0FBQSxTQUFBckIsYUFBQTtJQUMxQm1DLEdBQUEsR0FBQW5DLGFBQWtDLElBQWxDO01BQUFBO0lBQWlDLENBQUM7SUFBQXFCLENBQUEsT0FBQXJCLGFBQUE7SUFBQXFCLENBQUEsT0FBQWMsR0FBQTtFQUFBO0lBQUFBLEdBQUEsR0FBQWQsQ0FBQTtFQUFBO0VBQUEsSUFBQWUsR0FBQTtFQUFBLElBQUFmLENBQUEsU0FBQXBCLE9BQUE7SUFDbENtQyxHQUFBLEdBQUFuQyxPQUFzQixJQUF0QjtNQUFBQTtJQUFxQixDQUFDO0lBQUFvQixDQUFBLE9BQUFwQixPQUFBO0lBQUFvQixDQUFBLE9BQUFlLEdBQUE7RUFBQTtJQUFBQSxHQUFBLEdBQUFmLENBQUE7RUFBQTtFQUFBLElBQUFnQixHQUFBO0VBQUEsSUFBQWhCLENBQUEsU0FBQVksR0FBQSxJQUFBWixDQUFBLFNBQUFhLEdBQUEsSUFBQWIsQ0FBQSxTQUFBYyxHQUFBLElBQUFkLENBQUEsU0FBQWUsR0FBQSxJQUFBZixDQUFBLFNBQUFRLEVBQUEsSUFBQVIsQ0FBQSxTQUFBUyxFQUFBLElBQUFULENBQUEsU0FBQVUsRUFBQSxJQUFBVixDQUFBLFNBQUFXLEVBQUE7SUFSR0ssR0FBQTtNQUFBLEdBQ3pCUixFQUFrQjtNQUFBLEdBQ2xCQyxFQUFzQztNQUFBLEdBQ3RDQyxFQUFjO01BQUEsR0FDZEMsRUFBZ0I7TUFBQSxHQUNoQkMsR0FBb0I7TUFBQSxHQUNwQkMsR0FBMEI7TUFBQSxHQUMxQkMsR0FBa0M7TUFBQSxHQUNsQ0M7SUFDTixDQUFDO0lBQUFmLENBQUEsT0FBQVksR0FBQTtJQUFBWixDQUFBLE9BQUFhLEdBQUE7SUFBQWIsQ0FBQSxPQUFBYyxHQUFBO0lBQUFkLENBQUEsT0FBQWUsR0FBQTtJQUFBZixDQUFBLE9BQUFRLEVBQUE7SUFBQVIsQ0FBQSxPQUFBUyxFQUFBO0lBQUFULENBQUEsT0FBQVUsRUFBQTtJQUFBVixDQUFBLE9BQUFXLEVBQUE7SUFBQVgsQ0FBQSxPQUFBZ0IsR0FBQTtFQUFBO0lBQUFBLEdBQUEsR0FBQWhCLENBQUE7RUFBQTtFQVRELE1BQUFpQixVQUFBLEdBQStCRCxHQVM5QjtFQUdrQixNQUFBRSxHQUFBLEdBQUEvQixxQkFBcUIsQ0FBQ04sSUFBSSxDQUFDO0VBQUEsSUFBQXNDLEdBQUE7RUFBQSxJQUFBbkIsQ0FBQSxTQUFBbEIsUUFBQSxJQUFBa0IsQ0FBQSxTQUFBa0IsR0FBQSxJQUFBbEIsQ0FBQSxTQUFBaUIsVUFBQTtJQUE1Q0UsR0FBQSxZQUVXLENBRk0sS0FBMkIsQ0FBM0IsQ0FBQUQsR0FBMEIsQ0FBQyxDQUFjRCxVQUFVLENBQVZBLFdBQVMsQ0FBQyxDQUNqRW5DLFNBQU8sQ0FDVixFQUZBLFFBRVc7SUFBQWtCLENBQUEsT0FBQWxCLFFBQUE7SUFBQWtCLENBQUEsT0FBQWtCLEdBQUE7SUFBQWxCLENBQUEsT0FBQWlCLFVBQUE7SUFBQWpCLENBQUEsT0FBQW1CLEdBQUE7RUFBQTtJQUFBQSxHQUFBLEdBQUFuQixDQUFBO0VBQUE7RUFBQSxPQUZYbUIsR0FFVztBQUFBIiwiaWdub3JlTGlzdCI6W119 diff --git a/ui-tui/packages/hermes-ink/src/ink/constants.ts b/ui-tui/packages/hermes-ink/src/ink/constants.ts new file mode 100644 index 0000000000..1846997c0c --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/constants.ts @@ -0,0 +1,6 @@ +// Shared frame interval for render throttling and animations (~60fps). +export const FRAME_INTERVAL_MS = 16 + +// Keep clock-driven animations at full speed when terminal focus changes. +// We still pause entirely when there are no keepAlive subscribers. +export const BLURRED_FRAME_INTERVAL_MS = FRAME_INTERVAL_MS diff --git a/ui-tui/packages/hermes-ink/src/ink/cursor.ts b/ui-tui/packages/hermes-ink/src/ink/cursor.ts new file mode 100644 index 0000000000..fd37816712 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/cursor.ts @@ -0,0 +1,5 @@ +export type Cursor = { + x: number + y: number + visible: boolean +} diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts new file mode 100644 index 0000000000..121cd8b9b5 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -0,0 +1,485 @@ +import type { FocusManager } from './focus.js' +import { createLayoutNode } from './layout/engine.js' +import type { LayoutNode } from './layout/node.js' +import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js' +import measureText from './measure-text.js' +import { addPendingClear, nodeCache } from './node-cache.js' +import squashTextNodes from './squash-text-nodes.js' +import type { Styles, TextStyles } from './styles.js' +import { expandTabs } from './tabstops.js' +import wrapText from './wrap-text.js' + +type InkNode = { + parentNode: DOMElement | undefined + yogaNode?: LayoutNode + style: Styles +} + +export type TextName = '#text' +export type ElementNames = + | 'ink-root' + | 'ink-box' + | 'ink-text' + | 'ink-virtual-text' + | 'ink-link' + | 'ink-progress' + | 'ink-raw-ansi' + +export type NodeNames = ElementNames | TextName + +export type DOMElement = { + nodeName: ElementNames + attributes: Record + childNodes: DOMNode[] + textStyles?: TextStyles + + // Internal properties + onComputeLayout?: () => void + onRender?: () => void + onImmediateRender?: () => void + // Used to skip empty renders during React 19's effect double-invoke in test mode + hasRenderedContent?: boolean + + // When true, this node needs re-rendering + dirty: boolean + // Set by the reconciler's hideInstance/unhideInstance; survives style updates. + isHidden?: boolean + // Event handlers set by the reconciler for the capture/bubble dispatcher. + // Stored separately from attributes so handler identity changes don't + // mark dirty and defeat the blit optimization. + _eventHandlers?: Record + + // Scroll state for overflow: 'scroll' boxes. scrollTop is the number of + // rows the content is scrolled down by. scrollHeight/scrollViewportHeight + // are computed at render time and stored for imperative access. stickyScroll + // auto-pins scrollTop to the bottom when content grows. + scrollTop?: number + // Accumulated scroll delta not yet applied to scrollTop. The renderer + // drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show + // intermediate frames instead of one big jump. Direction reversal + // naturally cancels (pure accumulator, no target tracking). + pendingScrollDelta?: number + // Render-time clamp bounds for virtual scroll. useVirtualScroll writes + // the currently-mounted children's coverage span; render-node-to-output + // clamps scrollTop to stay within it. Prevents blank screen when + // scrollTo's direct write races past React's async re-render — instead + // of painting spacer (blank), the renderer holds at the edge of mounted + // content until React catches up (next commit updates these bounds and + // the clamp releases). Undefined = no clamp (sticky-scroll, cold start). + scrollClampMin?: number + scrollClampMax?: number + scrollHeight?: number + scrollViewportHeight?: number + scrollViewportTop?: number + stickyScroll?: boolean + // Set by ScrollBox.scrollToElement; render-node-to-output reads + // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight) + // and sets scrollTop = top + offset, then clears this. Unlike an + // imperative scrollTo(N) which bakes in a number that's stale by the + // time the throttled render fires, the element ref defers the position + // read to paint time. One-shot. + scrollAnchor?: { el: DOMElement; offset: number } + // Only set on ink-root. The document owns focus — any node can + // reach it by walking parentNode, like browser getRootNode(). + focusManager?: FocusManager + // React component stack captured at createInstance time (reconciler.ts), + // e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when + // CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to + // attribute scrollback-diff full-resets to the component that caused them. + debugOwnerChain?: string[] +} & InkNode + +export type TextNode = { + nodeName: TextName + nodeValue: string +} & InkNode + +export type DOMNode = T extends { + nodeName: infer U +} + ? U extends '#text' + ? TextNode + : DOMElement + : never + +export type DOMNodeAttribute = boolean | string | number + +export const createNode = (nodeName: ElementNames): DOMElement => { + const needsYogaNode = nodeName !== 'ink-virtual-text' && nodeName !== 'ink-link' && nodeName !== 'ink-progress' + + const node: DOMElement = { + nodeName, + style: {}, + attributes: {}, + childNodes: [], + parentNode: undefined, + yogaNode: needsYogaNode ? createLayoutNode() : undefined, + dirty: false + } + + if (nodeName === 'ink-text') { + node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)) + } else if (nodeName === 'ink-raw-ansi') { + node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node)) + } + + return node +} + +export const appendChildNode = (node: DOMElement, childNode: DOMElement): void => { + if (childNode.parentNode) { + removeChildNode(childNode.parentNode, childNode) + } + + childNode.parentNode = node + node.childNodes.push(childNode) + + if (childNode.yogaNode) { + node.yogaNode?.insertChild(childNode.yogaNode, node.yogaNode.getChildCount()) + } + + markDirty(node) +} + +export const insertBeforeNode = (node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode): void => { + if (newChildNode.parentNode) { + removeChildNode(newChildNode.parentNode, newChildNode) + } + + newChildNode.parentNode = node + + const index = node.childNodes.indexOf(beforeChildNode) + + if (index >= 0) { + // Calculate yoga index BEFORE modifying childNodes. + // We can't use DOM index directly because some children (like ink-progress, + // ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't + // match yoga indices. + let yogaIndex = 0 + + if (newChildNode.yogaNode && node.yogaNode) { + for (let i = 0; i < index; i++) { + if (node.childNodes[i]?.yogaNode) { + yogaIndex++ + } + } + } + + node.childNodes.splice(index, 0, newChildNode) + + if (newChildNode.yogaNode && node.yogaNode) { + node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex) + } + + markDirty(node) + + return + } + + node.childNodes.push(newChildNode) + + if (newChildNode.yogaNode) { + node.yogaNode?.insertChild(newChildNode.yogaNode, node.yogaNode.getChildCount()) + } + + markDirty(node) +} + +export const removeChildNode = (node: DOMElement, removeNode: DOMNode): void => { + if (removeNode.yogaNode) { + removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode) + } + + // Collect cached rects from the removed subtree so they can be cleared + collectRemovedRects(node, removeNode) + + removeNode.parentNode = undefined + + const index = node.childNodes.indexOf(removeNode) + + if (index >= 0) { + node.childNodes.splice(index, 1) + } + + markDirty(node) +} + +function collectRemovedRects(parent: DOMElement, removed: DOMNode, underAbsolute = false): void { + if (removed.nodeName === '#text') { + return + } + + const elem = removed as DOMElement + // If this node or any ancestor in the removed subtree was absolute, + // its painted pixels may overlap non-siblings — flag for global blit + // disable. Normal-flow removals only affect direct siblings, which + // hasRemovedChild already handles. + const isAbsolute = underAbsolute || elem.style.position === 'absolute' + const cached = nodeCache.get(elem) + + if (cached) { + addPendingClear(parent, cached, isAbsolute) + nodeCache.delete(elem) + } + + for (const child of elem.childNodes) { + collectRemovedRects(parent, child, isAbsolute) + } +} + +export const setAttribute = (node: DOMElement, key: string, value: DOMNodeAttribute): void => { + // Skip 'children' - React handles children via appendChild/removeChild, + // not attributes. React always passes a new children reference, so + // tracking it as an attribute would mark everything dirty every render. + if (key === 'children') { + return + } + + // Skip if unchanged + if (node.attributes[key] === value) { + return + } + + node.attributes[key] = value + markDirty(node) +} + +export const setStyle = (node: DOMNode, style: Styles): void => { + // Compare style properties to avoid marking dirty unnecessarily. + // React creates new style objects on every render even when unchanged. + if (stylesEqual(node.style, style)) { + return + } + + node.style = style + markDirty(node) +} + +export const setTextStyles = (node: DOMElement, textStyles: TextStyles): void => { + // Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx) + // allocate a new textStyles object on every render even when values are + // unchanged, so compare by value to avoid markDirty -> yoga re-measurement + // on every Text re-render. + if (shallowEqual(node.textStyles, textStyles)) { + return + } + + node.textStyles = textStyles + markDirty(node) +} + +function stylesEqual(a: Styles, b: Styles): boolean { + return shallowEqual(a, b) +} + +function shallowEqual(a: T | undefined, b: T | undefined): boolean { + // Fast path: same object reference (or both undefined) + if (a === b) { + return true + } + + if (a === undefined || b === undefined) { + return false + } + + // Get all keys from both objects + const aKeys = Object.keys(a) as (keyof T)[] + const bKeys = Object.keys(b) as (keyof T)[] + + // Different number of properties + if (aKeys.length !== bKeys.length) { + return false + } + + // Compare each property + for (const key of aKeys) { + if (a[key] !== b[key]) { + return false + } + } + + return true +} + +export const createTextNode = (text: string): TextNode => { + const node: TextNode = { + nodeName: '#text', + nodeValue: text, + yogaNode: undefined, + parentNode: undefined, + style: {} + } + + setTextNodeValue(node, text) + + return node +} + +const measureTextNode = function ( + node: DOMNode, + width: number, + widthMode: LayoutMeasureMode +): { width: number; height: number } { + const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node) + + // Expand tabs for measurement (worst case: 8 spaces each). + // Actual tab expansion happens in output.ts based on screen position. + const text = expandTabs(rawText) + + const dimensions = measureText(text, width) + + // Text fits into container, no need to wrap + if (dimensions.width <= width) { + return dimensions + } + + // This is happening when is shrinking child nodes and layout asks + // if we can fit this text node in a <1px space, so we just say "no" + if (dimensions.width >= 1 && width > 0 && width < 1) { + return dimensions + } + + // For text with embedded newlines (pre-wrapped content), avoid re-wrapping + // at measurement width when layout is asking for intrinsic size (Undefined mode). + // This prevents height inflation during min/max size checks. + // + // However, when layout provides an actual constraint (Exactly or AtMost mode), + // we must respect it and measure at that width. Otherwise, if the actual + // rendering width is smaller than the natural width, the text will wrap to + // more lines than layout expects, causing content to be truncated. + if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) { + const effectiveWidth = Math.max(width, dimensions.width) + + return measureText(text, effectiveWidth) + } + + const textWrap = node.style?.textWrap ?? 'wrap' + const wrappedText = wrapText(text, width, textWrap) + + return measureText(wrappedText, width) +} + +// ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions. +// No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff) +// already wrapped to the target width and each line is exactly one terminal row. +const measureRawAnsiNode = function (node: DOMElement): { + width: number + height: number +} { + return { + width: node.attributes['rawWidth'] as number, + height: node.attributes['rawHeight'] as number + } +} + +/** + * Mark a node and all its ancestors as dirty for re-rendering. + * Also marks yoga dirty for text remeasurement if this is a text node. + */ +export const markDirty = (node?: DOMNode): void => { + let current: DOMNode | undefined = node + let markedYoga = false + + while (current) { + if (current.nodeName !== '#text') { + ;(current as DOMElement).dirty = true + + // Only mark yoga dirty on leaf nodes that have measure functions + if (!markedYoga && (current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') && current.yogaNode) { + current.yogaNode.markDirty() + markedYoga = true + } + } + + current = current.parentNode + } +} + +// Walk to root and call its onRender (the throttled scheduleRender). Use for +// DOM-level mutations (scrollTop changes) that should trigger an Ink frame +// without going through React's reconciler. Pair with markDirty() so the +// renderer knows which subtree to re-evaluate. +export const scheduleRenderFrom = (node?: DOMNode): void => { + let cur: DOMNode | undefined = node + + while (cur?.parentNode) { + cur = cur.parentNode + } + + if (cur && cur.nodeName !== '#text') { + ;(cur as DOMElement).onRender?.() + } +} + +export const setTextNodeValue = (node: TextNode, text: string): void => { + if (typeof text !== 'string') { + text = String(text) + } + + // Skip if unchanged + if (node.nodeValue === text) { + return + } + + node.nodeValue = text + markDirty(node) +} + +function isDOMElement(node: DOMElement | TextNode): node is DOMElement { + return node.nodeName !== '#text' +} + +// Clear yogaNode references recursively before freeing. +// freeRecursive() frees the node and ALL its children, so we must clear +// all yogaNode references to prevent dangling pointers. +export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { + if ('childNodes' in node) { + for (const child of node.childNodes) { + clearYogaNodeReferences(child) + } + } + + node.yogaNode = undefined +} + +/** + * Find the React component stack responsible for content at screen row `y`. + * + * DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of + * the deepest node whose bounding box contains `y`. Called from ink.tsx when + * log-update triggers a full reset, to attribute the flicker to its source. + * + * Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are + * undefined and this returns []). + */ +export function findOwnerChainAtRow(root: DOMElement, y: number): string[] { + let best: string[] = [] + walk(root, 0) + + return best + + function walk(node: DOMElement, offsetY: number): void { + const yoga = node.yogaNode + + if (!yoga || yoga.getDisplay() === LayoutDisplay.None) { + return + } + + const top = offsetY + yoga.getComputedTop() + const height = yoga.getComputedHeight() + + if (y < top || y >= top + height) { + return + } + + if (node.debugOwnerChain) { + best = node.debugOwnerChain + } + + for (const child of node.childNodes) { + if (isDOMElement(child)) { + walk(child, top) + } + } + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/click-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/click-event.ts new file mode 100644 index 0000000000..1f58659a89 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/click-event.ts @@ -0,0 +1,38 @@ +import { Event } from './event.js' + +/** + * Mouse click event. Fired on left-button release without drag, only when + * mouse tracking is enabled (i.e. inside ). + * + * Bubbles from the deepest hit node up through parentNode. Call + * stopImmediatePropagation() to prevent ancestors' onClick from firing. + */ +export class ClickEvent extends Event { + /** 0-indexed screen column of the click */ + readonly col: number + /** 0-indexed screen row of the click */ + readonly row: number + /** + * Click column relative to the current handler's Box (col - box.x). + * Recomputed by dispatchClick before each handler fires, so an onClick + * on a container sees coords relative to that container, not to any + * child the click landed on. + */ + localCol = 0 + /** Click row relative to the current handler's Box (row - box.y). */ + localRow = 0 + /** + * True if the clicked cell has no visible content (unwritten in the + * screen buffer — both packed words are 0). Handlers can check this to + * ignore clicks on blank space to the right of text, so accidental + * clicks on empty terminal space don't toggle state. + */ + readonly cellIsBlank: boolean + + constructor(col: number, row: number, cellIsBlank: boolean) { + super() + this.col = col + this.row = row + this.cellIsBlank = cellIsBlank + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts b/ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts new file mode 100644 index 0000000000..1357da1dda --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/dispatcher.ts @@ -0,0 +1,242 @@ +import { + ContinuousEventPriority, + DefaultEventPriority, + DiscreteEventPriority, + NoEventPriority +} from 'react-reconciler/constants.js' + +import { logError } from '../../utils/log.js' + +import { HANDLER_FOR_EVENT } from './event-handlers.js' +import type { EventTarget, TerminalEvent } from './terminal-event.js' + +// -- + +type DispatchListener = { + node: EventTarget + handler: (event: TerminalEvent) => void + phase: 'capturing' | 'at_target' | 'bubbling' +} + +function getHandler( + node: EventTarget, + eventType: string, + capture: boolean +): ((event: TerminalEvent) => void) | undefined { + const handlers = node._eventHandlers + + if (!handlers) { + return undefined + } + + const mapping = HANDLER_FOR_EVENT[eventType] + + if (!mapping) { + return undefined + } + + const propName = capture ? mapping.capture : mapping.bubble + + if (!propName) { + return undefined + } + + return handlers[propName] as ((event: TerminalEvent) => void) | undefined +} + +/** + * Collect all listeners for an event in dispatch order. + * + * Uses react-dom's two-phase accumulation pattern: + * - Walk from target to root + * - Capture handlers are prepended (unshift) → root-first + * - Bubble handlers are appended (push) → target-first + * + * Result: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub] + */ +function collectListeners(target: EventTarget, event: TerminalEvent): DispatchListener[] { + const listeners: DispatchListener[] = [] + + let node: EventTarget | undefined = target + + while (node) { + const isTarget = node === target + + const captureHandler = getHandler(node, event.type, true) + const bubbleHandler = getHandler(node, event.type, false) + + if (captureHandler) { + listeners.unshift({ + node, + handler: captureHandler, + phase: isTarget ? 'at_target' : 'capturing' + }) + } + + if (bubbleHandler && (event.bubbles || isTarget)) { + listeners.push({ + node, + handler: bubbleHandler, + phase: isTarget ? 'at_target' : 'bubbling' + }) + } + + node = node.parentNode + } + + return listeners +} + +/** + * Execute collected listeners with propagation control. + * + * Before each handler, calls event._prepareForTarget(node) so event + * subclasses can do per-node setup. + */ +function processDispatchQueue(listeners: DispatchListener[], event: TerminalEvent): void { + let previousNode: EventTarget | undefined + + for (const { node, handler, phase } of listeners) { + if (event._isImmediatePropagationStopped()) { + break + } + + if (event._isPropagationStopped() && node !== previousNode) { + break + } + + event._setEventPhase(phase) + event._setCurrentTarget(node) + event._prepareForTarget(node) + + try { + handler(event) + } catch (error) { + logError(error) + } + + previousNode = node + } +} + +// -- + +/** + * Map terminal event types to React scheduling priorities. + * Mirrors react-dom's getEventPriority() switch. + */ +function getEventPriority(eventType: string): number { + switch (eventType) { + case 'keydown': + + case 'keyup': + + case 'click': + + case 'focus': + + case 'blur': + + case 'paste': + return DiscreteEventPriority as number + + case 'resize': + + case 'scroll': + + case 'mousemove': + return ContinuousEventPriority as number + + default: + return DefaultEventPriority as number + } +} + +// -- + +type DiscreteUpdates = (fn: (a: A, b: B) => boolean, a: A, b: B, c: undefined, d: undefined) => boolean + +/** + * Owns event dispatch state and the capture/bubble dispatch loop. + * + * The reconciler host config reads currentEvent and currentUpdatePriority + * to implement resolveUpdatePriority, resolveEventType, and + * resolveEventTimeStamp — mirroring how react-dom's host config reads + * ReactDOMSharedInternals and window.event. + * + * discreteUpdates is injected after construction (by InkReconciler) + * to break the import cycle. + */ +export class Dispatcher { + currentEvent: TerminalEvent | null = null + currentUpdatePriority: number = DefaultEventPriority as number + discreteUpdates: DiscreteUpdates | null = null + + /** + * Infer event priority from the currently-dispatching event. + * Called by the reconciler host config's resolveUpdatePriority + * when no explicit priority has been set. + */ + resolveEventPriority(): number { + if (this.currentUpdatePriority !== (NoEventPriority as number)) { + return this.currentUpdatePriority + } + + if (this.currentEvent) { + return getEventPriority(this.currentEvent.type) + } + + return DefaultEventPriority as number + } + + /** + * Dispatch an event through capture and bubble phases. + * Returns true if preventDefault() was NOT called. + */ + dispatch(target: EventTarget, event: TerminalEvent): boolean { + const previousEvent = this.currentEvent + this.currentEvent = event + + try { + event._setTarget(target) + + const listeners = collectListeners(target, event) + processDispatchQueue(listeners, event) + + event._setEventPhase('none') + event._setCurrentTarget(null) + + return !event.defaultPrevented + } finally { + this.currentEvent = previousEvent + } + } + + /** + * Dispatch with discrete (sync) priority. + * For user-initiated events: keyboard, click, focus, paste. + */ + dispatchDiscrete(target: EventTarget, event: TerminalEvent): boolean { + if (!this.discreteUpdates) { + return this.dispatch(target, event) + } + + return this.discreteUpdates((t, e) => this.dispatch(t, e), target, event, undefined, undefined) + } + + /** + * Dispatch with continuous priority. + * For high-frequency events: resize, scroll, mouse move. + */ + dispatchContinuous(target: EventTarget, event: TerminalEvent): boolean { + const previousPriority = this.currentUpdatePriority + + try { + this.currentUpdatePriority = ContinuousEventPriority as number + + return this.dispatch(target, event) + } finally { + this.currentUpdatePriority = previousPriority + } + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/emitter.ts b/ui-tui/packages/hermes-ink/src/ink/events/emitter.ts new file mode 100644 index 0000000000..d00c4d9e3c --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/emitter.ts @@ -0,0 +1,40 @@ +import { EventEmitter as NodeEventEmitter } from 'events' + +import { Event } from './event.js' + +// Similar to node's builtin EventEmitter, but is also aware of our `Event` +// class, and so `emit` respects `stopImmediatePropagation()`. +export class EventEmitter extends NodeEventEmitter { + constructor() { + super() + // Disable the default maxListeners warning. In React, many components + // can legitimately listen to the same event (e.g., useInput hooks). + // The default limit of 10 causes spurious warnings. + this.setMaxListeners(0) + } + + override emit(type: string | symbol, ...args: unknown[]): boolean { + // Delegate to node for `error`, since it's not treated like a normal event + if (type === 'error') { + return super.emit(type, ...args) + } + + const listeners = this.rawListeners(type) + + if (listeners.length === 0) { + return false + } + + const ccEvent = args[0] instanceof Event ? args[0] : null + + for (const listener of listeners) { + listener.apply(this, args) + + if (ccEvent?.didStopImmediatePropagation()) { + break + } + } + + return true + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts b/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts new file mode 100644 index 0000000000..42d59d0353 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts @@ -0,0 +1,73 @@ +import type { ClickEvent } from './click-event.js' +import type { FocusEvent } from './focus-event.js' +import type { KeyboardEvent } from './keyboard-event.js' +import type { PasteEvent } from './paste-event.js' +import type { ResizeEvent } from './resize-event.js' + +type KeyboardEventHandler = (event: KeyboardEvent) => void +type FocusEventHandler = (event: FocusEvent) => void +type PasteEventHandler = (event: PasteEvent) => void +type ResizeEventHandler = (event: ResizeEvent) => void +type ClickEventHandler = (event: ClickEvent) => void +type HoverEventHandler = () => void + +/** + * Props for event handlers on Box and other host components. + * + * Follows the React/DOM naming convention: + * - onEventName: handler for bubble phase + * - onEventNameCapture: handler for capture phase + */ +export type EventHandlerProps = { + onKeyDown?: KeyboardEventHandler + onKeyDownCapture?: KeyboardEventHandler + + onFocus?: FocusEventHandler + onFocusCapture?: FocusEventHandler + onBlur?: FocusEventHandler + onBlurCapture?: FocusEventHandler + + onPaste?: PasteEventHandler + onPasteCapture?: PasteEventHandler + + onResize?: ResizeEventHandler + + onClick?: ClickEventHandler + onMouseEnter?: HoverEventHandler + onMouseLeave?: HoverEventHandler +} + +/** + * Reverse lookup: event type string → handler prop names. + * Used by the dispatcher for O(1) handler lookup per node. + */ +export const HANDLER_FOR_EVENT: Record< + string, + { bubble?: keyof EventHandlerProps; capture?: keyof EventHandlerProps } +> = { + keydown: { bubble: 'onKeyDown', capture: 'onKeyDownCapture' }, + focus: { bubble: 'onFocus', capture: 'onFocusCapture' }, + blur: { bubble: 'onBlur', capture: 'onBlurCapture' }, + paste: { bubble: 'onPaste', capture: 'onPasteCapture' }, + resize: { bubble: 'onResize' }, + click: { bubble: 'onClick' } +} + +/** + * Set of all event handler prop names, for the reconciler to detect + * event props and store them in _eventHandlers instead of attributes. + */ +export const EVENT_HANDLER_PROPS = new Set([ + 'onKeyDown', + 'onKeyDownCapture', + 'onFocus', + 'onFocusCapture', + 'onBlur', + 'onBlurCapture', + 'onPaste', + 'onPasteCapture', + 'onResize', + 'onClick', + 'onMouseEnter', + 'onMouseLeave' +]) diff --git a/ui-tui/packages/hermes-ink/src/ink/events/event.ts b/ui-tui/packages/hermes-ink/src/ink/events/event.ts new file mode 100644 index 0000000000..61874002eb --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/event.ts @@ -0,0 +1,11 @@ +export class Event { + private _didStopImmediatePropagation = false + + didStopImmediatePropagation(): boolean { + return this._didStopImmediatePropagation + } + + stopImmediatePropagation(): void { + this._didStopImmediatePropagation = true + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts new file mode 100644 index 0000000000..527fd26d22 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/focus-event.ts @@ -0,0 +1,18 @@ +import { type EventTarget, TerminalEvent } from './terminal-event.js' + +/** + * Focus event for component focus changes. + * + * Dispatched when focus moves between elements. 'focus' fires on the + * newly focused element, 'blur' fires on the previously focused one. + * Both bubble, matching react-dom's use of focusin/focusout semantics + * so parent components can observe descendant focus changes. + */ +export class FocusEvent extends TerminalEvent { + readonly relatedTarget: EventTarget | null + + constructor(type: 'focus' | 'blur', relatedTarget: EventTarget | null = null) { + super(type, { bubbles: true, cancelable: false }) + this.relatedTarget = relatedTarget + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts new file mode 100644 index 0000000000..293ecdbeec --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts @@ -0,0 +1,184 @@ +import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js' + +import { Event } from './event.js' + +export type Key = { + upArrow: boolean + downArrow: boolean + leftArrow: boolean + rightArrow: boolean + pageDown: boolean + pageUp: boolean + wheelUp: boolean + wheelDown: boolean + home: boolean + end: boolean + return: boolean + escape: boolean + ctrl: boolean + shift: boolean + fn: boolean + tab: boolean + backspace: boolean + delete: boolean + meta: boolean + super: boolean +} + +function parseKey(keypress: ParsedKey): [Key, string] { + const key: Key = { + upArrow: keypress.name === 'up', + downArrow: keypress.name === 'down', + leftArrow: keypress.name === 'left', + rightArrow: keypress.name === 'right', + pageDown: keypress.name === 'pagedown', + pageUp: keypress.name === 'pageup', + wheelUp: keypress.name === 'wheelup', + wheelDown: keypress.name === 'wheeldown', + home: keypress.name === 'home', + end: keypress.name === 'end', + return: keypress.name === 'return', + escape: keypress.name === 'escape', + fn: keypress.fn, + ctrl: keypress.ctrl, + shift: keypress.shift, + tab: keypress.name === 'tab', + backspace: keypress.name === 'backspace', + delete: keypress.name === 'delete', + // `parseKeypress` parses \u001B\u001B[A (meta + up arrow) as meta = false + // but with option = true, so we need to take this into account here + // to avoid breaking changes in Ink. + // TODO(vadimdemedes): consider removing this in the next major version. + meta: keypress.meta || keypress.name === 'escape' || keypress.option, + // Super (Cmd on macOS / Win key) — only arrives via kitty keyboard + // protocol CSI u sequences. Distinct from meta (Alt/Option) so + // bindings like cmd+c can be expressed separately from opt+c. + super: keypress.super + } + + let input = keypress.ctrl ? keypress.name : keypress.sequence + + // Handle undefined input case + if (input === undefined) { + input = '' + } + + // When ctrl is set, keypress.name for space is the literal word "space". + // Convert to actual space character for consistency with the CSI u branch + // (which maps 'space' → ' '). Without this, ctrl+space leaks the literal + // word "space" into text input. + if (keypress.ctrl && input === 'space') { + input = ' ' + } + + // Suppress unrecognized escape sequences that were parsed as function keys + // (matched by FN_KEY_RE) but have no name in the keyName map. + // Examples: ESC[25~ (F13/Right Alt on Windows), ESC[26~ (F14), etc. + // Without this, the ESC prefix is stripped below and the remainder (e.g., + // "[25~") leaks into the input as literal text. + if (keypress.code && !keypress.name) { + input = '' + } + + // Suppress ESC-less SGR mouse fragments. When a heavy React commit blocks + // the event loop past App's 50ms NORMAL_TIMEOUT flush, a CSI split across + // stdin chunks gets its buffered ESC flushed as a lone Escape key, and the + // continuation arrives as a text token with name='' — which falls through + // all of parseKeypress's ESC-anchored regexes and the nonAlphanumericKeys + // clear below (name is falsy). The fragment then leaks into the prompt as + // literal `[<64;74;16M`. This is the same defensive sink as the F13 guard + // above; the underlying tokenizer-flush race is upstream of this layer. + if (!keypress.name && /^\[<\d+;\d+;\d+[Mm]/.test(input)) { + input = '' + } + + // Strip meta if it's still remaining after `parseKeypress` + // TODO(vadimdemedes): remove this in the next major version. + if (input.startsWith('\u001B')) { + input = input.slice(1) + } + + // Track whether we've already processed this as a special sequence + // that converted input to the key name (CSI u or application keypad mode). + // For these, we don't want to clear input with nonAlphanumericKeys check. + let processedAsSpecialSequence = false + + // Handle CSI u sequences (Kitty keyboard protocol): after stripping ESC, + // we're left with "[codepoint;modifieru" (e.g., "[98;3u" for Alt+b). + // Use the parsed key name instead for input handling. Require a digit + // after [ — real CSI u is always […u, and a bare startsWith('[') + // false-matches X10 mouse at row 85 (Cy = 85+32 = 'u'), leaking the + // literal text "mouse" into the prompt via processedAsSpecialSequence. + if (/^\[\d/.test(input) && input.endsWith('u')) { + if (!keypress.name) { + // Unmapped Kitty functional key (Caps Lock 57358, F13–F35, KP nav, + // bare modifiers, etc.) — keycodeToName() returned undefined. Swallow + // so the raw "[57358u" doesn't leak into the prompt. See #38781. + input = '' + } else { + // 'space' → ' '; 'escape' → '' (key.escape carries it; + // processedAsSpecialSequence bypasses the nonAlphanumericKeys + // clear below, so we must handle it explicitly here); + // otherwise use key name. + input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name + } + + processedAsSpecialSequence = true + } + + // Handle xterm modifyOtherKeys sequences: after stripping ESC, we're left + // with "[27;modifier;keycode~" (e.g., "[27;3;98~" for Alt+b). Same + // extraction as CSI u — without this, printable-char keycodes (single-letter + // names) skip the nonAlphanumericKeys clear and leak "[27;..." as input. + if (input.startsWith('[27;') && input.endsWith('~')) { + if (!keypress.name) { + // Unmapped modifyOtherKeys keycode — swallow for consistency with + // the CSI u handler above. Practically untriggerable today (xterm + // modifyOtherKeys only sends ASCII keycodes, all mapped), but + // guards against future terminal behavior. + input = '' + } else { + input = keypress.name === 'space' ? ' ' : keypress.name === 'escape' ? '' : keypress.name + } + + processedAsSpecialSequence = true + } + + // Handle application keypad mode sequences: after stripping ESC, + // we're left with "O" (e.g., "Op" for numpad 0, "Oy" for numpad 9). + // Use the parsed key name (the digit character) for input handling. + if (input.startsWith('O') && input.length === 2 && keypress.name && keypress.name.length === 1) { + input = keypress.name + processedAsSpecialSequence = true + } + + // Clear input for non-alphanumeric keys (arrows, function keys, etc.) + // Skip this for CSI u and application keypad mode sequences since + // those were already converted to their proper input characters. + if (!processedAsSpecialSequence && keypress.name && nonAlphanumericKeys.includes(keypress.name)) { + input = '' + } + + // Set shift=true for uppercase letters (A-Z) + // Must check it's actually a letter, not just any char unchanged by toUpperCase + if (input.length === 1 && typeof input[0] === 'string' && input[0] >= 'A' && input[0] <= 'Z') { + key.shift = true + } + + return [key, input] +} + +export class InputEvent extends Event { + readonly keypress: ParsedKey + readonly key: Key + readonly input: string + + constructor(keypress: ParsedKey) { + super() + const [key, input] = parseKey(keypress) + + this.keypress = keypress + this.key = key + this.input = input + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts new file mode 100644 index 0000000000..6d441dadbd --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/keyboard-event.ts @@ -0,0 +1,57 @@ +import type { ParsedKey } from '../parse-keypress.js' + +import { TerminalEvent } from './terminal-event.js' + +/** + * Keyboard event dispatched through the DOM tree via capture/bubble. + * + * Follows browser KeyboardEvent semantics: `key` is the literal character + * for printable keys ('a', '3', ' ', '/') and a multi-char name for + * special keys ('down', 'return', 'escape', 'f1'). The idiomatic + * printable-char check is `e.key.length === 1`. + */ +export class KeyboardEvent extends TerminalEvent { + readonly key: string + readonly ctrl: boolean + readonly shift: boolean + readonly meta: boolean + readonly superKey: boolean + readonly fn: boolean + + constructor(parsedKey: ParsedKey) { + super('keydown', { bubbles: true, cancelable: true }) + + this.key = keyFromParsed(parsedKey) + this.ctrl = parsedKey.ctrl + this.shift = parsedKey.shift + this.meta = parsedKey.meta || parsedKey.option + this.superKey = parsedKey.super + this.fn = parsedKey.fn + } +} + +function keyFromParsed(parsed: ParsedKey): string { + const seq = parsed.sequence ?? '' + const name = parsed.name ?? '' + + // Ctrl combos: sequence is a control byte (\x03 for ctrl+c), name is the + // letter. Browsers report e.key === 'c' with e.ctrlKey === true. + if (parsed.ctrl) { + return name + } + + // Single printable char (space through ~, plus anything above ASCII): + // use the literal char. Browsers report e.key === '3', not 'Digit3'. + if (seq.length === 1) { + const code = seq.charCodeAt(0) + + if (code >= 0x20 && code !== 0x7f) { + return seq + } + } + + // Special keys (arrows, F-keys, return, tab, escape, etc.): sequence is + // either an escape sequence (\x1b[B) or a control byte (\r, \t), so use + // the parsed name. Browsers report e.key === 'ArrowDown'. + return name || seq +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/terminal-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/terminal-event.ts new file mode 100644 index 0000000000..9a86bf8b29 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/terminal-event.ts @@ -0,0 +1,107 @@ +import { Event } from './event.js' + +type EventPhase = 'none' | 'capturing' | 'at_target' | 'bubbling' + +type TerminalEventInit = { + bubbles?: boolean + cancelable?: boolean +} + +/** + * Base class for all terminal events with DOM-style propagation. + * + * Extends Event so existing event types (ClickEvent, InputEvent, + * TerminalFocusEvent) share a common ancestor and can migrate later. + * + * Mirrors the browser's Event API: target, currentTarget, eventPhase, + * stopPropagation(), preventDefault(), timeStamp. + */ +export class TerminalEvent extends Event { + readonly type: string + readonly timeStamp: number + readonly bubbles: boolean + readonly cancelable: boolean + + private _target: EventTarget | null = null + private _currentTarget: EventTarget | null = null + private _eventPhase: EventPhase = 'none' + private _propagationStopped = false + private _defaultPrevented = false + + constructor(type: string, init?: TerminalEventInit) { + super() + this.type = type + this.timeStamp = performance.now() + this.bubbles = init?.bubbles ?? true + this.cancelable = init?.cancelable ?? true + } + + get target(): EventTarget | null { + return this._target + } + + get currentTarget(): EventTarget | null { + return this._currentTarget + } + + get eventPhase(): EventPhase { + return this._eventPhase + } + + get defaultPrevented(): boolean { + return this._defaultPrevented + } + + stopPropagation(): void { + this._propagationStopped = true + } + + override stopImmediatePropagation(): void { + super.stopImmediatePropagation() + this._propagationStopped = true + } + + preventDefault(): void { + if (this.cancelable) { + this._defaultPrevented = true + } + } + + // -- Internal setters used by the Dispatcher + + /** @internal */ + _setTarget(target: EventTarget): void { + this._target = target + } + + /** @internal */ + _setCurrentTarget(target: EventTarget | null): void { + this._currentTarget = target + } + + /** @internal */ + _setEventPhase(phase: EventPhase): void { + this._eventPhase = phase + } + + /** @internal */ + _isPropagationStopped(): boolean { + return this._propagationStopped + } + + /** @internal */ + _isImmediatePropagationStopped(): boolean { + return this.didStopImmediatePropagation() + } + + /** + * Hook for subclasses to do per-node setup before each handler fires. + * Default is a no-op. + */ + _prepareForTarget(_target: EventTarget): void {} +} + +export type EventTarget = { + parentNode: EventTarget | undefined + _eventHandlers?: Record +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/terminal-focus-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/terminal-focus-event.ts new file mode 100644 index 0000000000..6d0303fdb4 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/terminal-focus-event.ts @@ -0,0 +1,19 @@ +import { Event } from './event.js' + +export type TerminalFocusEventType = 'terminalfocus' | 'terminalblur' + +/** + * Event fired when the terminal window gains or loses focus. + * + * Uses DECSET 1004 focus reporting - the terminal sends: + * - CSI I (\x1b[I) when the terminal gains focus + * - CSI O (\x1b[O) when the terminal loses focus + */ +export class TerminalFocusEvent extends Event { + readonly type: TerminalFocusEventType + + constructor(type: TerminalFocusEventType) { + super() + this.type = type + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/focus.ts b/ui-tui/packages/hermes-ink/src/ink/focus.ts new file mode 100644 index 0000000000..0317ed9d7e --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/focus.ts @@ -0,0 +1,219 @@ +import type { DOMElement } from './dom.js' +import { FocusEvent } from './events/focus-event.js' + +const MAX_FOCUS_STACK = 32 + +/** + * DOM-like focus manager for the Ink terminal UI. + * + * Pure state — tracks activeElement and a focus stack. Has no reference + * to the tree; callers pass the root when tree walks are needed. + * + * Stored on the root DOMElement so any node can reach it by walking + * parentNode (like browser's `node.ownerDocument`). + */ +export class FocusManager { + activeElement: DOMElement | null = null + private dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean + private enabled = true + private focusStack: DOMElement[] = [] + + constructor(dispatchFocusEvent: (target: DOMElement, event: FocusEvent) => boolean) { + this.dispatchFocusEvent = dispatchFocusEvent + } + + focus(node: DOMElement): void { + if (node === this.activeElement) { + return + } + + if (!this.enabled) { + return + } + + const previous = this.activeElement + + if (previous) { + // Deduplicate before pushing to prevent unbounded growth from Tab cycling + const idx = this.focusStack.indexOf(previous) + + if (idx !== -1) { + this.focusStack.splice(idx, 1) + } + + this.focusStack.push(previous) + + if (this.focusStack.length > MAX_FOCUS_STACK) { + this.focusStack.shift() + } + + this.dispatchFocusEvent(previous, new FocusEvent('blur', node)) + } + + this.activeElement = node + this.dispatchFocusEvent(node, new FocusEvent('focus', previous)) + } + + blur(): void { + if (!this.activeElement) { + return + } + + const previous = this.activeElement + this.activeElement = null + this.dispatchFocusEvent(previous, new FocusEvent('blur', null)) + } + + /** + * Called by the reconciler when a node is removed from the tree. + * Handles both the exact node and any focused descendant within + * the removed subtree. Dispatches blur and restores focus from stack. + */ + handleNodeRemoved(node: DOMElement, root: DOMElement): void { + // Remove the node and any descendants from the stack + this.focusStack = this.focusStack.filter(n => n !== node && isInTree(n, root)) + + // Check if activeElement is the removed node OR a descendant + if (!this.activeElement) { + return + } + + if (this.activeElement !== node && isInTree(this.activeElement, root)) { + return + } + + const removed = this.activeElement + this.activeElement = null + this.dispatchFocusEvent(removed, new FocusEvent('blur', null)) + + // Restore focus to the most recent still-mounted element + while (this.focusStack.length > 0) { + const candidate = this.focusStack.pop()! + + if (isInTree(candidate, root)) { + this.activeElement = candidate + this.dispatchFocusEvent(candidate, new FocusEvent('focus', removed)) + + return + } + } + } + + handleAutoFocus(node: DOMElement): void { + this.focus(node) + } + + handleClickFocus(node: DOMElement): void { + const tabIndex = node.attributes['tabIndex'] + + if (typeof tabIndex !== 'number') { + return + } + + this.focus(node) + } + + enable(): void { + this.enabled = true + } + + disable(): void { + this.enabled = false + } + + focusNext(root: DOMElement): void { + this.moveFocus(1, root) + } + + focusPrevious(root: DOMElement): void { + this.moveFocus(-1, root) + } + + private moveFocus(direction: 1 | -1, root: DOMElement): void { + if (!this.enabled) { + return + } + + const tabbable = collectTabbable(root) + + if (tabbable.length === 0) { + return + } + + const currentIndex = this.activeElement ? tabbable.indexOf(this.activeElement) : -1 + + const nextIndex = + currentIndex === -1 + ? direction === 1 + ? 0 + : tabbable.length - 1 + : (currentIndex + direction + tabbable.length) % tabbable.length + + const next = tabbable[nextIndex] + + if (next) { + this.focus(next) + } + } +} + +function collectTabbable(root: DOMElement): DOMElement[] { + const result: DOMElement[] = [] + walkTree(root, result) + + return result +} + +function walkTree(node: DOMElement, result: DOMElement[]): void { + const tabIndex = node.attributes['tabIndex'] + + if (typeof tabIndex === 'number' && tabIndex >= 0) { + result.push(node) + } + + for (const child of node.childNodes) { + if (child.nodeName !== '#text') { + walkTree(child, result) + } + } +} + +function isInTree(node: DOMElement, root: DOMElement): boolean { + let current: DOMElement | undefined = node + + while (current) { + if (current === root) { + return true + } + + current = current.parentNode + } + + return false +} + +/** + * Walk up to root and return it. The root is the node that holds + * the FocusManager — like browser's `node.getRootNode()`. + */ +export function getRootNode(node: DOMElement): DOMElement { + let current: DOMElement | undefined = node + + while (current) { + if (current.focusManager) { + return current + } + + current = current.parentNode + } + + throw new Error('Node is not in a tree with a FocusManager') +} + +/** + * Walk up to root and return its FocusManager. + * Like browser's `node.ownerDocument` — focus belongs to the root. + */ +export function getFocusManager(node: DOMElement): FocusManager { + return getRootNode(node).focusManager! +} diff --git a/ui-tui/packages/hermes-ink/src/ink/frame.ts b/ui-tui/packages/hermes-ink/src/ink/frame.ts new file mode 100644 index 0000000000..869afa5f9d --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/frame.ts @@ -0,0 +1,116 @@ +import type { Cursor } from './cursor.js' +import type { Size } from './layout/geometry.js' +import type { ScrollHint } from './render-node-to-output.js' +import { type CharPool, createScreen, type HyperlinkPool, type Screen, type StylePool } from './screen.js' + +export type Frame = { + readonly screen: Screen + readonly viewport: Size + readonly cursor: Cursor + /** DECSTBM scroll optimization hint (alt-screen only, null otherwise). */ + readonly scrollHint?: ScrollHint | null + /** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */ + readonly scrollDrainPending?: boolean +} + +export function emptyFrame( + rows: number, + columns: number, + stylePool: StylePool, + charPool: CharPool, + hyperlinkPool: HyperlinkPool +): Frame { + return { + screen: createScreen(0, 0, stylePool, charPool, hyperlinkPool), + viewport: { width: columns, height: rows }, + cursor: { x: 0, y: 0, visible: true } + } +} + +export type FlickerReason = 'resize' | 'offscreen' | 'clear' + +export type FrameEvent = { + durationMs: number + /** Phase breakdown in ms + patch count. Populated when the ink instance + * has frame-timing instrumentation enabled (via onFrame wiring). */ + phases?: { + /** createRenderer output: DOM → yoga layout → screen buffer */ + renderer: number + /** LogUpdate.render(): screen diff → Patch[] (the hot path this PR optimizes) */ + diff: number + /** optimize(): patch merge/dedupe */ + optimize: number + /** writeDiffToTerminal(): serialize patches → ANSI → stdout */ + write: number + /** Pre-optimize patch count (proxy for how much changed this frame) */ + patches: number + /** yoga calculateLayout() time (runs in resetAfterCommit, before onRender) */ + yoga: number + /** React reconcile time: scrollMutated → resetAfterCommit. 0 if no commit. */ + commit: number + /** layoutNode() calls this frame (recursive, includes cache-hit returns) */ + yogaVisited: number + /** measureFunc (text wrap/width) calls — the expensive part */ + yogaMeasured: number + /** early returns via _hasL single-slot cache */ + yogaCacheHits: number + /** total yoga Node instances alive (create - free). Growth = leak. */ + yogaLive: number + } + flickers: Array<{ + desiredHeight: number + availableHeight: number + reason: FlickerReason + }> +} + +export type Patch = + | { type: 'stdout'; content: string } + | { type: 'clear'; count: number } + | { + type: 'clearTerminal' + reason: FlickerReason + // Populated by log-update when a scrollback diff triggers the reset. + // ink.tsx uses triggerY with findOwnerChainAtRow to attribute the + // flicker to its source React component. + debug?: { triggerY: number; prevLine: string; nextLine: string } + } + | { type: 'cursorHide' } + | { type: 'cursorShow' } + | { type: 'cursorMove'; x: number; y: number } + | { type: 'cursorTo'; col: number } + | { type: 'carriageReturn' } + | { type: 'hyperlink'; uri: string } + // Pre-serialized style transition string from StylePool.transition() — + // cached by (fromId, toId), zero allocations after warmup. + | { type: 'styleStr'; str: string } + +export type Diff = Patch[] + +/** + * Determines whether the screen should be cleared based on the current and previous frame. + * Returns the reason for clearing, or undefined if no clear is needed. + * + * Screen clearing is triggered when: + * 1. Terminal has been resized (viewport dimensions changed) → 'resize' + * 2. Current frame screen height exceeds available terminal rows → 'offscreen' + * 3. Previous frame screen height exceeded available terminal rows → 'offscreen' + */ +export function shouldClearScreen(prevFrame: Frame, frame: Frame): FlickerReason | undefined { + const didResize = + frame.viewport.height !== prevFrame.viewport.height || frame.viewport.width !== prevFrame.viewport.width + + if (didResize) { + return 'resize' + } + + const currentFrameOverflows = frame.screen.height >= frame.viewport.height + + const previousFrameOverflowed = prevFrame.screen.height >= prevFrame.viewport.height + + if (currentFrameOverflows || previousFrameOverflowed) { + return 'offscreen' + } + + return undefined +} diff --git a/ui-tui/packages/hermes-ink/src/ink/get-max-width.ts b/ui-tui/packages/hermes-ink/src/ink/get-max-width.ts new file mode 100644 index 0000000000..e079463748 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/get-max-width.ts @@ -0,0 +1,27 @@ +import { LayoutEdge, type LayoutNode } from './layout/node.js' + +/** + * Returns the yoga node's content width (computed width minus padding and + * border). + * + * Warning: can return a value WIDER than the parent container. In a + * column-direction flex parent, width is the cross axis — align-items: + * stretch never shrinks children below their intrinsic size, so the text + * node overflows (standard CSS behavior). Yoga measures leaf nodes in two + * passes: the AtMost pass determines width, the Exactly pass determines + * height. getComputedWidth() reflects the wider AtMost result while + * getComputedHeight() reflects the narrower Exactly result. Callers that + * use this for wrapping should clamp to actual available screen space so + * the rendered line count stays consistent with the layout height. + */ +const getMaxWidth = (yogaNode: LayoutNode): number => { + return ( + yogaNode.getComputedWidth() - + yogaNode.getComputedPadding(LayoutEdge.Left) - + yogaNode.getComputedPadding(LayoutEdge.Right) - + yogaNode.getComputedBorder(LayoutEdge.Left) - + yogaNode.getComputedBorder(LayoutEdge.Right) + ) +} + +export default getMaxWidth diff --git a/ui-tui/packages/hermes-ink/src/ink/global.d.ts b/ui-tui/packages/hermes-ink/src/ink/global.d.ts new file mode 100644 index 0000000000..336ce12bb9 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/global.d.ts @@ -0,0 +1 @@ +export {} diff --git a/ui-tui/packages/hermes-ink/src/ink/hit-test.ts b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts new file mode 100644 index 0000000000..f0d9a31792 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts @@ -0,0 +1,146 @@ +import type { DOMElement } from './dom.js' +import { ClickEvent } from './events/click-event.js' +import type { EventHandlerProps } from './events/event-handlers.js' +import { nodeCache } from './node-cache.js' + +/** + * Find the deepest DOM element whose rendered rect contains (col, row). + * + * Uses the nodeCache populated by renderNodeToOutput — rects are in screen + * coordinates with all offsets (including scrollTop translation) already + * applied. Children are traversed in reverse so later siblings (painted on + * top) win. Nodes not in nodeCache (not rendered this frame, or lacking a + * yogaNode) are skipped along with their subtrees. + * + * Returns the hit node even if it has no onClick — dispatchClick walks up + * via parentNode to find handlers. + */ +export function hitTest(node: DOMElement, col: number, row: number): DOMElement | null { + const rect = nodeCache.get(node) + + if (!rect) { + return null + } + + if (col < rect.x || col >= rect.x + rect.width || row < rect.y || row >= rect.y + rect.height) { + return null + } + + // Later siblings paint on top; reversed traversal returns topmost hit. + for (let i = node.childNodes.length - 1; i >= 0; i--) { + const child = node.childNodes[i]! + + if (child.nodeName === '#text') { + continue + } + + const hit = hitTest(child, col, row) + + if (hit) { + return hit + } + } + + return node +} + +/** + * Hit-test the root at (col, row) and bubble a ClickEvent from the deepest + * containing node up through parentNode. Only nodes with an onClick handler + * fire. Stops when a handler calls stopImmediatePropagation(). Returns + * true if at least one onClick handler fired. + */ +export function dispatchClick(root: DOMElement, col: number, row: number, cellIsBlank = false): boolean { + let target: DOMElement | undefined = hitTest(root, col, row) ?? undefined + + if (!target) { + return false + } + + // Click-to-focus: find the closest focusable ancestor and focus it. + // root is always ink-root, which owns the FocusManager. + if (root.focusManager) { + let focusTarget: DOMElement | undefined = target + + while (focusTarget) { + if (typeof focusTarget.attributes['tabIndex'] === 'number') { + root.focusManager.handleClickFocus(focusTarget) + + break + } + + focusTarget = focusTarget.parentNode + } + } + + const event = new ClickEvent(col, row, cellIsBlank) + let handled = false + + while (target) { + const handler = target._eventHandlers?.onClick as ((event: ClickEvent) => void) | undefined + + if (handler) { + handled = true + const rect = nodeCache.get(target) + + if (rect) { + event.localCol = col - rect.x + event.localRow = row - rect.y + } + + handler(event) + + if (event.didStopImmediatePropagation()) { + return true + } + } + + target = target.parentNode + } + + return handled +} + +/** + * Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM + * mouseenter/mouseleave: does NOT bubble — moving between children does + * not re-fire on the parent. Walks up from the hit node collecting every + * ancestor with a hover handler; diffs against the previous hovered set; + * fires leave on the nodes exited, enter on the nodes entered. + * + * Mutates `hovered` in place so the caller (App instance) can hold it + * across calls. Clears the set when the hit is null (cursor moved into a + * non-rendered gap or off the root rect). + */ +export function dispatchHover(root: DOMElement, col: number, row: number, hovered: Set): void { + const next = new Set() + let node: DOMElement | undefined = hitTest(root, col, row) ?? undefined + + while (node) { + const h = node._eventHandlers as EventHandlerProps | undefined + + if (h?.onMouseEnter || h?.onMouseLeave) { + next.add(node) + } + + node = node.parentNode + } + + for (const old of hovered) { + if (!next.has(old)) { + hovered.delete(old) + + // Skip handlers on detached nodes (removed between mouse events) + if (old.parentNode) { + ;(old._eventHandlers as EventHandlerProps | undefined)?.onMouseLeave?.() + } + } + } + + for (const n of next) { + if (!hovered.has(n)) { + hovered.add(n) + ;(n._eventHandlers as EventHandlerProps | undefined)?.onMouseEnter?.() + } + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-animation-frame.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-animation-frame.ts new file mode 100644 index 0000000000..0eef9e1aba --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-animation-frame.ts @@ -0,0 +1,62 @@ +import { useContext, useEffect, useState } from 'react' + +import { ClockContext } from '../components/ClockContext.js' +import type { DOMElement } from '../dom.js' + +import { useTerminalViewport } from './use-terminal-viewport.js' + +/** + * Hook for synchronized animations that pause when offscreen. + * + * Returns a ref to attach to the animated element and the current animation time. + * All instances share the same clock, so animations stay in sync. + * The clock only runs when at least one keepAlive subscriber exists. + * + * Pass `null` to pause — unsubscribes from the clock so no ticks fire. + * Time freezes at the last value and resumes from the current clock time + * when a number is passed again. + * + * @param intervalMs - How often to update, or null to pause + * @returns [ref, time] - Ref to attach to element, elapsed time in ms + * + * @example + * function Spinner() { + * const [ref, time] = useAnimationFrame(120) + * const frame = Math.floor(time / 120) % FRAMES.length + * return {FRAMES[frame]} + * } + * + * The clock automatically slows when the terminal is blurred, + * so consumers don't need to handle focus state. + */ +export function useAnimationFrame( + intervalMs: number | null = 16 +): [ref: (element: DOMElement | null) => void, time: number] { + const clock = useContext(ClockContext) + const [viewportRef, { isVisible }] = useTerminalViewport() + const [time, setTime] = useState(() => clock?.now() ?? 0) + + const active = isVisible && intervalMs !== null + + useEffect(() => { + if (!clock || !active) { + return + } + + let lastUpdate = clock.now() + + const onChange = (): void => { + const now = clock.now() + + if (now - lastUpdate >= intervalMs!) { + lastUpdate = now + setTime(now) + } + } + + // keepAlive: true — visible animations drive the clock + return clock.subscribe(onChange, true) + }, [clock, intervalMs, active]) + + return [viewportRef, time] +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-app.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-app.ts new file mode 100644 index 0000000000..9c06032448 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-app.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react' + +import AppContext from '../components/AppContext.js' + +/** + * `useApp` is a React hook, which exposes a method to manually exit the app (unmount). + */ +const useApp = () => useContext(AppContext) +export default useApp diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-declared-cursor.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-declared-cursor.ts new file mode 100644 index 0000000000..288a92eda3 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-declared-cursor.ts @@ -0,0 +1,75 @@ +import { useCallback, useContext, useLayoutEffect, useRef } from 'react' + +import CursorDeclarationContext from '../components/CursorDeclarationContext.js' +import type { DOMElement } from '../dom.js' + +/** + * Declares where the terminal cursor should be parked after each frame. + * + * Terminal emulators render IME preedit text at the physical cursor + * position, and screen readers / screen magnifiers track the native + * cursor — so parking it at the text input's caret makes CJK input + * appear inline and lets accessibility tools follow the input. + * + * Returns a ref callback to attach to the Box that contains the input. + * The declared (line, column) is interpreted relative to that Box's + * nodeCache rect (populated by renderNodeToOutput). + * + * Timing: Both ref attach and useLayoutEffect fire in React's layout + * phase — after resetAfterCommit calls scheduleRender. scheduleRender + * defers onRender via queueMicrotask, so onRender runs AFTER layout + * effects commit and reads the fresh declaration on the first frame + * (no one-keystroke lag). Test env uses onImmediateRender (synchronous, + * no microtask), so tests compensate by calling ink.onRender() + * explicitly after render. + */ +export function useDeclaredCursor({ + line, + column, + active +}: { + line: number + column: number + active: boolean +}): (element: DOMElement | null) => void { + const setCursorDeclaration = useContext(CursorDeclarationContext) + const nodeRef = useRef(null) + + const setNode = useCallback((node: DOMElement | null) => { + nodeRef.current = node + }, []) + + // When active, set unconditionally. When inactive, clear conditionally + // (only if the currently-declared node is ours). The node-identity check + // handles two hazards: + // 1. A memo()ized active instance elsewhere (e.g. the search input in + // a memo'd Footer) doesn't re-render this commit — an inactive + // instance re-rendering here must not clobber it. + // 2. Sibling handoff (menu focus moving between list items) — when + // focus moves opposite to sibling order, the newly-inactive item's + // effect runs AFTER the newly-active item's set. Without the node + // check it would clobber. + // No dep array: must re-declare every commit so the active instance + // re-claims the declaration after another instance's unmount-cleanup or + // sibling handoff nulls it. + useLayoutEffect(() => { + const node = nodeRef.current + + if (active && node) { + setCursorDeclaration({ relativeX: column, relativeY: line, node }) + } else { + setCursorDeclaration(null, node) + } + }) + + // Clear on unmount (conditionally — another instance may own by then). + // Separate effect with empty deps so cleanup only fires once — not on + // every line/column change, which would transiently null between commits. + useLayoutEffect(() => { + return () => { + setCursorDeclaration(null, nodeRef.current) + } + }, [setCursorDeclaration]) + + return setNode +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-input.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-input.ts new file mode 100644 index 0000000000..edda48a4a7 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-input.ts @@ -0,0 +1,95 @@ +import { useEffect, useLayoutEffect } from 'react' +import { useEventCallback } from 'usehooks-ts' + +import type { InputEvent, Key } from '../events/input-event.js' + +import useStdin from './use-stdin.js' + +type Handler = (input: string, key: Key, event: InputEvent) => void + +type Options = { + /** + * Enable or disable capturing of user input. + * Useful when there are multiple useInput hooks used at once to avoid handling the same input several times. + * + * @default true + */ + isActive?: boolean +} + +/** + * This hook is used for handling user input. + * It's a more convenient alternative to using `StdinContext` and listening to `data` events. + * The callback you pass to `useInput` is called for each character when user enters any input. + * However, if user pastes text and it's more than one character, the callback will be called only once and the whole string will be passed as `input`. + * + * ``` + * import {useInput} from 'ink'; + * + * const UserInput = () => { + * useInput((input, key) => { + * if (input === 'q') { + * // Exit program + * } + * + * if (key.leftArrow) { + * // Left arrow key pressed + * } + * }); + * + * return … + * }; + * ``` + */ +const useInput = (inputHandler: Handler, options: Options = {}) => { + const { setRawMode, exitOnCtrlC, inputEmitter } = useStdin() + + // useLayoutEffect (not useEffect) so that raw mode is enabled synchronously + // during React's commit phase, before render() returns. With useEffect, raw + // mode setup is deferred to the next event loop tick via React's scheduler, + // leaving the terminal in cooked mode — keystrokes echo and the cursor is + // visible until the effect fires. + useLayoutEffect(() => { + if (options.isActive === false) { + return + } + + setRawMode(true) + + return () => { + setRawMode(false) + } + }, [options.isActive, setRawMode]) + + // Register the listener once on mount so its slot in the EventEmitter's + // listener array is stable. If isActive were in the effect's deps, the + // listener would re-append on false→true, moving it behind listeners + // that registered while it was inactive — breaking + // stopImmediatePropagation() ordering. useEventCallback keeps the + // reference stable while reading latest isActive/inputHandler from + // closure (it syncs via useLayoutEffect, so it's compiler-safe). + const handleData = useEventCallback((event: InputEvent) => { + if (options.isActive === false) { + return + } + + const { input, key } = event + + // If app is not supposed to exit on Ctrl+C, then let input listener handle it + // Note: discreteUpdates is called at the App level when emitting events, + // so all listeners are already within a high-priority update context. + if (!(input === 'c' && key.ctrl) || !exitOnCtrlC) { + inputHandler(input, key, event) + } + }) + + useEffect(() => { + inputEmitter?.on('input', handleData) + + return () => { + inputEmitter?.removeListener('input', handleData) + } + }, [inputEmitter, handleData]) +} + +export default useInput diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-interval.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-interval.ts new file mode 100644 index 0000000000..af568457bf --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-interval.ts @@ -0,0 +1,71 @@ +import { useContext, useEffect, useRef, useState } from 'react' + +import { ClockContext } from '../components/ClockContext.js' + +/** + * Returns the clock time, updating at the given interval. + * Subscribes as non-keepAlive — won't keep the clock alive on its own, + * but updates whenever a keepAlive subscriber (e.g. the spinner) + * is driving the clock. + * + * Use this to drive pure time-based computations (shimmer position, + * frame index) from the shared clock. + */ +export function useAnimationTimer(intervalMs: number): number { + const clock = useContext(ClockContext) + const [time, setTime] = useState(() => clock?.now() ?? 0) + + useEffect(() => { + if (!clock) { + return + } + + let lastUpdate = clock.now() + + const onChange = (): void => { + const now = clock.now() + + if (now - lastUpdate >= intervalMs) { + lastUpdate = now + setTime(now) + } + } + + return clock.subscribe(onChange, false) + }, [clock, intervalMs]) + + return time +} + +/** + * Interval hook backed by the shared Clock. + * + * Unlike `useInterval` from `usehooks-ts` (which creates its own setInterval), + * this piggybacks on the single shared clock so all timers consolidate into + * one wake-up. Pass `null` for intervalMs to pause. + */ +export function useInterval(callback: () => void, intervalMs: number | null): void { + const callbackRef = useRef(callback) + callbackRef.current = callback + + const clock = useContext(ClockContext) + + useEffect(() => { + if (!clock || intervalMs === null) { + return + } + + let lastUpdate = clock.now() + + const onChange = (): void => { + const now = clock.now() + + if (now - lastUpdate >= intervalMs) { + lastUpdate = now + callbackRef.current() + } + } + + return clock.subscribe(onChange, false) + }, [clock, intervalMs]) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-search-highlight.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-search-highlight.ts new file mode 100644 index 0000000000..f43379a5e6 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-search-highlight.ts @@ -0,0 +1,56 @@ +import { useContext, useMemo } from 'react' + +import StdinContext from '../components/StdinContext.js' +import type { DOMElement } from '../dom.js' +import instances from '../instances.js' +import type { MatchPosition } from '../render-to-screen.js' + +/** + * Set the search highlight query on the Ink instance. Non-empty → all + * visible occurrences are inverted on the next frame (SGR 7, screen-buffer + * overlay, same damage machinery as selection). Empty → clears. + * + * This is a screen-space highlight — it matches the RENDERED text, not the + * source message text. Works for anything visible (bash output, file paths, + * error messages) regardless of where it came from in the message tree. A + * query that matched in source but got truncated/ellipsized in rendering + * won't highlight; that's acceptable — we highlight what you see. + */ +export function useSearchHighlight(): { + setQuery: (query: string) => void + /** Paint an existing DOM subtree (from the MAIN tree) to a fresh + * Screen at its natural height, scan. Element-relative positions + * (row 0 = element top). Zero context duplication — the element + * IS the one built with all real providers. */ + scanElement: (el: DOMElement) => MatchPosition[] + /** Position-based CURRENT highlight. Every frame writes yellow at + * positions[currentIdx] + rowOffset. The scan-highlight (inverse on + * all matches) still runs — this overlays on top. rowOffset tracks + * scroll; positions stay stable (message-relative). null clears. */ + setPositions: ( + state: { + positions: MatchPosition[] + rowOffset: number + currentIdx: number + } | null + ) => void +} { + useContext(StdinContext) // anchor to App subtree for hook rules + const ink = instances.get(process.stdout) + + return useMemo(() => { + if (!ink) { + return { + setQuery: () => {}, + scanElement: () => [], + setPositions: () => {} + } + } + + return { + setQuery: (query: string) => ink.setSearchHighlight(query), + scanElement: (el: DOMElement) => ink.scanElementSubtree(el), + setPositions: state => ink.setSearchPositions(state) + } + }, [ink]) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts new file mode 100644 index 0000000000..58761fe241 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-selection.ts @@ -0,0 +1,97 @@ +import { useContext, useMemo, useSyncExternalStore } from 'react' + +import StdinContext from '../components/StdinContext.js' +import instances from '../instances.js' +import { type FocusMove, type SelectionState, shiftAnchor } from '../selection.js' + +/** + * Access to text selection operations on the Ink instance (fullscreen only). + * Returns no-op functions when fullscreen mode is disabled. + */ +export function useSelection(): { + copySelection: () => string + /** Copy without clearing the highlight (for copy-on-select). */ + copySelectionNoClear: () => string + clearSelection: () => void + hasSelection: () => boolean + /** Read the raw mutable selection state (for drag-to-scroll). */ + getState: () => SelectionState | null + /** Subscribe to selection mutations (start/update/finish/clear). */ + subscribe: (cb: () => void) => () => void + /** Shift the anchor row by dRow, clamped to [minRow, maxRow]. */ + shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void + /** Shift anchor AND focus by dRow (keyboard scroll: whole selection + * tracks content). Clamped points get col reset to the full-width edge + * since their content was captured by captureScrolledRows. Reads + * screen.width from the ink instance for the col-reset boundary. */ + shiftSelection: (dRow: number, minRow: number, maxRow: number) => void + /** Keyboard selection extension (shift+arrow): move focus, anchor fixed. + * Left/right wrap across rows; up/down clamp at viewport edges. */ + moveFocus: (move: FocusMove) => void + /** Capture text from rows about to scroll out of the viewport (call + * BEFORE scrollBy so the screen buffer still has the outgoing rows). */ + captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void + /** Set the selection highlight bg color (theme-piping; solid bg + * replaces the old SGR-7 inverse so syntax highlighting stays readable + * under selection). Call once on mount + whenever theme changes. */ + setSelectionBgColor: (color: string) => void +} { + // Look up the Ink instance via stdout — same pattern as instances map. + // StdinContext is available (it's always provided), and the Ink instance + // is keyed by stdout which we can get from process.stdout since there's + // only one Ink instance per process in practice. + useContext(StdinContext) // anchor to App subtree for hook rules + const ink = instances.get(process.stdout) + + // Memoize so callers can safely use the return value in dependency arrays. + // ink is a singleton per stdout — stable across renders. + return useMemo(() => { + if (!ink) { + return { + copySelection: () => '', + copySelectionNoClear: () => '', + clearSelection: () => {}, + hasSelection: () => false, + getState: () => null, + subscribe: () => () => {}, + shiftAnchor: () => {}, + shiftSelection: () => {}, + moveFocus: () => {}, + captureScrolledRows: () => {}, + setSelectionBgColor: () => {} + } + } + + return { + copySelection: () => ink.copySelection(), + copySelectionNoClear: () => ink.copySelectionNoClear(), + clearSelection: () => ink.clearTextSelection(), + hasSelection: () => ink.hasTextSelection(), + getState: () => ink.selection, + subscribe: (cb: () => void) => ink.subscribeToSelectionChange(cb), + shiftAnchor: (dRow: number, minRow: number, maxRow: number) => shiftAnchor(ink.selection, dRow, minRow, maxRow), + shiftSelection: (dRow, minRow, maxRow) => ink.shiftSelectionForScroll(dRow, minRow, maxRow), + moveFocus: (move: FocusMove) => ink.moveSelectionFocus(move), + captureScrolledRows: (firstRow, lastRow, side) => ink.captureScrolledRows(firstRow, lastRow, side), + setSelectionBgColor: (color: string) => ink.setSelectionBgColor(color) + } + }, [ink]) +} + +const NO_SUBSCRIBE = () => () => {} +const ALWAYS_FALSE = () => false + +/** + * Reactive selection-exists state. Re-renders the caller when a text + * selection is created or cleared. Always returns false outside + * fullscreen mode (selection is only available in alt-screen). + */ +export function useHasSelection(): boolean { + useContext(StdinContext) + const ink = instances.get(process.stdout) + + return useSyncExternalStore( + ink ? ink.subscribeToSelectionChange : NO_SUBSCRIBE, + ink ? ink.hasTextSelection : ALWAYS_FALSE + ) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-stdin.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-stdin.ts new file mode 100644 index 0000000000..58cf746f57 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-stdin.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react' + +import StdinContext from '../components/StdinContext.js' + +/** + * `useStdin` is a React hook, which exposes stdin stream. + */ +const useStdin = () => useContext(StdinContext) +export default useStdin diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-tab-status.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-tab-status.ts new file mode 100644 index 0000000000..a3cdf17bc2 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-tab-status.ts @@ -0,0 +1,71 @@ +import { useContext, useEffect, useRef } from 'react' + +import { CLEAR_TAB_STATUS, supportsTabStatus, tabStatus, wrapForMultiplexer } from '../termio/osc.js' +import type { Color } from '../termio/types.js' +import { TerminalWriteContext } from '../useTerminalNotification.js' + +export type TabStatusKind = 'idle' | 'busy' | 'waiting' + +const rgb = (r: number, g: number, b: number): Color => ({ + type: 'rgb', + r, + g, + b +}) + +// Per the OSC 21337 usage guide's suggested mapping. +const TAB_STATUS_PRESETS: Record = { + idle: { + indicator: rgb(0, 215, 95), + status: 'Idle', + statusColor: rgb(136, 136, 136) + }, + busy: { + indicator: rgb(255, 149, 0), + status: 'Working…', + statusColor: rgb(255, 149, 0) + }, + waiting: { + indicator: rgb(95, 135, 255), + status: 'Waiting', + statusColor: rgb(95, 135, 255) + } +} + +/** + * Declaratively set the tab-status indicator (OSC 21337). + * + * Emits a colored dot + short status text to the tab sidebar. Terminals + * that don't support OSC 21337 discard the sequence silently, so this is + * safe to call unconditionally. Wrapped for tmux/screen passthrough. + * + * Pass `null` to opt out. If a status was previously set, transitioning to + * `null` emits CLEAR_TAB_STATUS so toggling off mid-session doesn't leave + * a stale dot. Process-exit cleanup is handled by ink.tsx's unmount path. + */ +export function useTabStatus(kind: TabStatusKind | null): void { + const writeRaw = useContext(TerminalWriteContext) + const prevKindRef = useRef(null) + + useEffect(() => { + // When kind transitions from non-null to null (e.g. user toggles off + // showStatusInTerminalTab mid-session), clear the stale dot. + if (kind === null) { + if (prevKindRef.current !== null && writeRaw && supportsTabStatus()) { + writeRaw(wrapForMultiplexer(CLEAR_TAB_STATUS)) + } + + prevKindRef.current = null + + return + } + + prevKindRef.current = kind + + if (!writeRaw || !supportsTabStatus()) { + return + } + + writeRaw(wrapForMultiplexer(tabStatus(TAB_STATUS_PRESETS[kind]))) + }, [kind, writeRaw]) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-focus.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-focus.ts new file mode 100644 index 0000000000..230d87a39f --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-focus.ts @@ -0,0 +1,18 @@ +import { useContext } from 'react' + +import TerminalFocusContext from '../components/TerminalFocusContext.js' + +/** + * Hook to check if the terminal has focus. + * + * Uses DECSET 1004 focus reporting - the terminal sends escape sequences + * when it gains or loses focus. These are handled automatically + * by Ink and filtered from useInput. + * + * @returns true if the terminal is focused (or focus state is unknown) + */ +export function useTerminalFocus(): boolean { + const { isTerminalFocused } = useContext(TerminalFocusContext) + + return isTerminalFocused +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts new file mode 100644 index 0000000000..6b5b28f5c3 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-title.ts @@ -0,0 +1,34 @@ +import { useContext, useEffect } from 'react' +import stripAnsi from 'strip-ansi' + +import { OSC, osc } from '../termio/osc.js' +import { TerminalWriteContext } from '../useTerminalNotification.js' + +/** + * Declaratively set the terminal tab/window title. + * + * Pass a string to set the title. ANSI escape sequences are stripped + * automatically so callers don't need to know about terminal encoding. + * Pass `null` to opt out — the hook becomes a no-op and leaves the + * terminal title untouched. + * + * On Windows, uses `process.title` (classic conhost doesn't support OSC). + * Elsewhere, writes OSC 0 (set title+icon) via Ink's stdout. + */ +export function useTerminalTitle(title: string | null): void { + const writeRaw = useContext(TerminalWriteContext) + + useEffect(() => { + if (title === null || !writeRaw) { + return + } + + const clean = stripAnsi(title) + + if (process.platform === 'win32') { + process.title = clean + } else { + writeRaw(osc(OSC.SET_TITLE_AND_ICON, clean)) + } + }, [title, writeRaw]) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-viewport.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-viewport.ts new file mode 100644 index 0000000000..ada3059d91 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-terminal-viewport.ts @@ -0,0 +1,100 @@ +import { useCallback, useContext, useLayoutEffect, useRef } from 'react' + +import { TerminalSizeContext } from '../components/TerminalSizeContext.js' +import type { DOMElement } from '../dom.js' + +type ViewportEntry = { + /** + * Whether the element is currently within the terminal viewport + */ + isVisible: boolean +} + +/** + * Hook to detect if a component is within the terminal viewport. + * + * Returns a callback ref and a viewport entry object. + * Attach the ref to the component you want to track. + * + * The entry is updated during the layout phase (useLayoutEffect) so callers + * always read fresh values during render. Visibility changes do NOT trigger + * re-renders on their own — callers that re-render for other reasons (e.g. + * animation ticks, state changes) will pick up the latest value naturally. + * This avoids infinite update loops when combined with other layout effects + * that also call setState. + * + * @example + * const [ref, entry] = useTerminalViewport() + * return ... + */ +export function useTerminalViewport(): [ref: (element: DOMElement | null) => void, entry: ViewportEntry] { + const terminalSize = useContext(TerminalSizeContext) + const elementRef = useRef(null) + const entryRef = useRef({ isVisible: true }) + + const setElement = useCallback((el: DOMElement | null) => { + elementRef.current = el + }, []) + + // Runs on every render because yoga layout values can change + // without React being aware. Only updates the ref — no setState + // to avoid cascading re-renders during the commit phase. + // Walks the DOM ancestor chain fresh each time to avoid holding stale + // references after yoga tree rebuilds. + useLayoutEffect(() => { + const element = elementRef.current + + if (!element?.yogaNode || !terminalSize) { + return + } + + const height = element.yogaNode.getComputedHeight() + const rows = terminalSize.rows + + // Walk the DOM parent chain (not yoga.getParent()) so we can detect + // scroll containers and subtract their scrollTop. Yoga computes layout + // positions without scroll offset — scrollTop is applied at render time. + // Without this, an element inside a ScrollBox whose yoga position exceeds + // terminalRows would be considered offscreen even when scrolled into view + // (e.g., the spinner in fullscreen mode after enough messages accumulate). + let absoluteTop = element.yogaNode.getComputedTop() + let parent: DOMElement | undefined = element.parentNode + let root = element.yogaNode + + while (parent) { + if (parent.yogaNode) { + absoluteTop += parent.yogaNode.getComputedTop() + root = parent.yogaNode + } + + // scrollTop is only ever set on scroll containers (by ScrollBox + renderer). + // Non-scroll nodes have undefined scrollTop → falsy fast-path. + if (parent.scrollTop) { + absoluteTop -= parent.scrollTop + } + + parent = parent.parentNode + } + + // Only the root's height matters + const screenHeight = root.getComputedHeight() + + const bottom = absoluteTop + height + // When content overflows the viewport (screenHeight > rows), the + // cursor-restore at frame end scrolls one extra row into scrollback. + // log-update.ts accounts for this with scrollbackRows = viewportY + 1. + // We must match, otherwise an element at the boundary is considered + // "visible" here (animation keeps ticking) but its row is treated as + // scrollback by log-update (content change → full reset → flicker). + const cursorRestoreScroll = screenHeight > rows ? 1 : 0 + const viewportY = Math.max(0, screenHeight - rows) + cursorRestoreScroll + const viewportBottom = viewportY + rows + const visible = bottom > viewportY && absoluteTop < viewportBottom + + if (visible !== entryRef.current.isVisible) { + entryRef.current = { isVisible: visible } + } + }) + + return [setElement, entryRef.current] +} diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx new file mode 100644 index 0000000000..5b15167d5b --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -0,0 +1,2140 @@ +import { closeSync, constants as fsConstants, openSync, readSync, writeSync } from 'fs' +import { format } from 'util' + +import autoBind from 'auto-bind' +import noop from 'lodash-es/noop.js' +import throttle from 'lodash-es/throttle.js' +import React, { type ReactNode } from 'react' +import type { FiberRoot } from 'react-reconciler' +import { ConcurrentRoot } from 'react-reconciler/constants.js' +import { onExit } from 'signal-exit' + +import { flushInteractionTime } from '../bootstrap/state.js' +import { getYogaCounters } from '../native-ts/yoga-layout/index.js' +import { logForDebugging } from '../utils/debug.js' +import { logError } from '../utils/log.js' + +import { colorize } from './colorize.js' +import App from './components/App.js' +import type { CursorDeclaration, CursorDeclarationSetter } from './components/CursorDeclarationContext.js' +import { FRAME_INTERVAL_MS } from './constants.js' +import * as dom from './dom.js' +import { KeyboardEvent } from './events/keyboard-event.js' +import { FocusManager } from './focus.js' +import { emptyFrame, type Frame, type FrameEvent } from './frame.js' +import { dispatchClick, dispatchHover } from './hit-test.js' +import instances from './instances.js' +import { LogUpdate } from './log-update.js' +import { nodeCache } from './node-cache.js' +import { optimize } from './optimizer.js' +import Output from './output.js' +import type { ParsedKey } from './parse-keypress.js' +import reconciler, { + dispatcher, + getLastCommitMs, + getLastYogaMs, + isDebugRepaintsEnabled, + recordYogaMs, + resetProfileCounters +} from './reconciler.js' +import renderNodeToOutput, { consumeFollowScroll, didLayoutShift } from './render-node-to-output.js' +import { applyPositionedHighlight, type MatchPosition, scanPositions } from './render-to-screen.js' +import createRenderer, { type Renderer } from './renderer.js' +import { + cellAt, + CellWidth, + CharPool, + createScreen, + HyperlinkPool, + isEmptyCellAt, + migrateScreenPools, + StylePool +} from './screen.js' +import { applySearchHighlight } from './searchHighlight.js' +import { + applySelectionOverlay, + captureScrolledRows, + clearSelection, + createSelectionState, + extendSelection, + findPlainTextUrlAt, + type FocusMove, + getSelectedText, + hasSelection, + moveFocus, + type SelectionState, + selectLineAt, + selectWordAt, + shiftAnchor, + shiftSelection, + shiftSelectionForFollow, + startSelection, + updateSelection +} from './selection.js' +import { supportsExtendedKeys, SYNC_OUTPUT_SUPPORTED, type Terminal, writeDiffToTerminal } from './terminal.js' +import { + CURSOR_HOME, + cursorMove, + cursorPosition, + DISABLE_KITTY_KEYBOARD, + DISABLE_MODIFY_OTHER_KEYS, + ENABLE_KITTY_KEYBOARD, + ENABLE_MODIFY_OTHER_KEYS, + ERASE_SCREEN +} from './termio/csi.js' +import { + DBP, + DFE, + DISABLE_MOUSE_TRACKING, + ENABLE_MOUSE_TRACKING, + ENTER_ALT_SCREEN, + EXIT_ALT_SCREEN, + SHOW_CURSOR +} from './termio/dec.js' +import { + CLEAR_ITERM2_PROGRESS, + CLEAR_TAB_STATUS, + setClipboard, + supportsTabStatus, + wrapForMultiplexer +} from './termio/osc.js' +import { TerminalWriteProvider } from './useTerminalNotification.js' + +// Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0, +// which is always false in alt-screen (TTY + content fills screen). +// Reusing a frozen object saves 1 allocation per frame. +const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ + x: 0, + y: 0, + visible: false +}) + +const CURSOR_HOME_PATCH = Object.freeze({ + type: 'stdout' as const, + content: CURSOR_HOME +}) + +const ERASE_THEN_HOME_PATCH = Object.freeze({ + type: 'stdout' as const, + content: ERASE_SCREEN + CURSOR_HOME +}) + +// Cached per-Ink-instance, invalidated on resize. frame.cursor.y for +// alt-screen is always terminalRows - 1 (renderer.ts). +function makeAltScreenParkPatch(terminalRows: number) { + return Object.freeze({ + type: 'stdout' as const, + content: cursorPosition(terminalRows, 1) + }) +} + +export type Options = { + stdout: NodeJS.WriteStream + stdin: NodeJS.ReadStream + stderr: NodeJS.WriteStream + exitOnCtrlC: boolean + patchConsole: boolean + waitUntilExit?: () => Promise + onFrame?: (event: FrameEvent) => void +} +export default class Ink { + private readonly log: LogUpdate + private readonly terminal: Terminal + private scheduleRender: (() => void) & { + cancel?: () => void + } + // Ignore last render after unmounting a tree to prevent empty output before exit + private isUnmounted = false + private isPaused = false + private readonly container: FiberRoot + private rootNode: dom.DOMElement + readonly focusManager: FocusManager + private renderer: Renderer + private readonly stylePool: StylePool + private charPool: CharPool + private hyperlinkPool: HyperlinkPool + private exitPromise?: Promise + private restoreConsole?: () => void + private restoreStderr?: () => void + private readonly unsubscribeTTYHandlers?: () => void + private terminalColumns: number + private terminalRows: number + private currentNode: ReactNode = null + private frontFrame: Frame + private backFrame: Frame + private lastPoolResetTime = performance.now() + private drainTimer: ReturnType | null = null + private lastYogaCounters: { + ms: number + visited: number + measured: number + cacheHits: number + live: number + } = { + ms: 0, + visited: 0, + measured: 0, + cacheHits: 0, + live: 0 + } + private altScreenParkPatch: Readonly<{ + type: 'stdout' + content: string + }> + // Text selection state (alt-screen only). Owned here so the overlay + // pass in onRender can read it and App.tsx can update it from mouse + // events. Public so instances.get() callers can access. + readonly selection: SelectionState = createSelectionState() + // Search highlight query (alt-screen only). Setter below triggers + // scheduleRender; applySearchHighlight in onRender inverts matching cells. + private searchHighlightQuery = '' + // Position-based highlight. VML scans positions ONCE (via + // scanElementSubtree, when the target message is mounted), stores them + // message-relative, sets this for every-frame apply. rowOffset = + // message's current screen-top. currentIdx = which position is + // "current" (yellow). null clears. Positions are known upfront — + // navigation is index arithmetic, no scan-feedback loop. + private searchPositions: { + positions: MatchPosition[] + rowOffset: number + currentIdx: number + } | null = null + // React-land subscribers for selection state changes (useHasSelection). + // Fired alongside the terminal repaint whenever the selection mutates + // so UI (e.g. footer hints) can react to selection appearing/clearing. + private readonly selectionListeners = new Set<() => void>() + // DOM nodes currently under the pointer (mode-1003 motion). Held here + // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs + // against this set and mutates it in place. + private readonly hoveredNodes = new Set() + // Set by via setAltScreenActive(). Controls the + // renderer's cursor.y clamping (keeps cursor in-viewport to avoid + // LF-induced scroll when screen.height === terminalRows) and gates + // alt-screen-aware SIGCONT/resize/unmount handling. + private altScreenActive = false + // Set alongside altScreenActive so SIGCONT resume knows whether to + // re-enable mouse tracking (not all uses want it). + private altScreenMouseTracking = false + // True when the previous frame's screen buffer cannot be trusted for + // blit — selection overlay mutated it, resetFramesForAltScreen() + // replaced it with blanks, or forceRedraw() reset it to 0×0. Forces + // one full-render frame; steady-state frames after clear it and regain + // the blit + narrow-damage fast path. + private prevFrameContaminated = false + // Set by handleResize: prepend ERASE_SCREEN to the next onRender's patches + // INSIDE the BSU/ESU block so clear+paint is atomic. Writing ERASE_SCREEN + // synchronously in handleResize would leave the screen blank for the ~80ms + // render() takes; deferring into the atomic block means old content stays + // visible until the new frame is fully ready. + private needsEraseBeforePaint = false + // Native cursor positioning: a component (via useDeclaredCursor) declares + // where the terminal cursor should be parked after each frame. Terminal + // emulators render IME preedit text at the physical cursor position, and + // screen readers / screen magnifiers track it — so parking at the text + // input's caret makes CJK input appear inline and lets a11y tools follow. + private cursorDeclaration: CursorDeclaration | null = null + // Main-screen: physical cursor position after the declared-cursor move, + // tracked separately from frame.cursor (which must stay at content-bottom + // for log-update's relative-move invariants). Alt-screen doesn't need + // this — every frame begins with CSI H. null = no move emitted last frame. + private displayCursor: { + x: number + y: number + } | null = null + constructor(private readonly options: Options) { + autoBind(this) + + if (this.options.patchConsole) { + this.restoreConsole = this.patchConsole() + this.restoreStderr = this.patchStderr() + } + + this.terminal = { + stdout: options.stdout, + stderr: options.stderr + } + this.terminalColumns = options.stdout.columns || 80 + this.terminalRows = options.stdout.rows || 24 + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + this.stylePool = new StylePool() + this.charPool = new CharPool() + this.hyperlinkPool = new HyperlinkPool() + this.frontFrame = emptyFrame( + this.terminalRows, + this.terminalColumns, + this.stylePool, + this.charPool, + this.hyperlinkPool + ) + this.backFrame = emptyFrame( + this.terminalRows, + this.terminalColumns, + this.stylePool, + this.charPool, + this.hyperlinkPool + ) + this.log = new LogUpdate({ + isTTY: (options.stdout.isTTY as boolean | undefined) || false, + stylePool: this.stylePool + }) + + // scheduleRender is called from the reconciler's resetAfterCommit, which + // runs BEFORE React's layout phase (ref attach + useLayoutEffect). Any + // state set in layout effects — notably the cursorDeclaration from + // useDeclaredCursor — would lag one commit behind if we rendered + // synchronously. Deferring to a microtask runs onRender after layout + // effects have committed, so the native cursor tracks the caret without + // a one-keystroke lag. Same event-loop tick, so throughput is unchanged. + // Test env uses onImmediateRender (direct onRender, no throttle) so + // existing synchronous lastFrame() tests are unaffected. + const deferredRender = (): void => queueMicrotask(this.onRender) + this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, { + leading: true, + trailing: true + }) + + // Ignore last render after unmounting a tree to prevent empty output before exit + this.isUnmounted = false + + // Unmount when process exits + this.unsubscribeExit = onExit(this.unmount, { + alwaysLast: false + }) + + if (options.stdout.isTTY) { + options.stdout.on('resize', this.handleResize) + process.on('SIGCONT', this.handleResume) + + this.unsubscribeTTYHandlers = () => { + options.stdout.off('resize', this.handleResize) + process.off('SIGCONT', this.handleResume) + } + } + + this.rootNode = dom.createNode('ink-root') + this.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event)) + this.rootNode.focusManager = this.focusManager + this.renderer = createRenderer(this.rootNode, this.stylePool) + this.rootNode.onRender = this.scheduleRender + this.rootNode.onImmediateRender = this.onRender + + this.rootNode.onComputeLayout = () => { + // Calculate layout during React's commit phase so useLayoutEffect hooks + // have access to fresh layout data + // Guard against accessing freed Yoga nodes after unmount + if (this.isUnmounted) { + return + } + + if (this.rootNode.yogaNode) { + const t0 = performance.now() + this.rootNode.yogaNode.setWidth(this.terminalColumns) + this.rootNode.yogaNode.calculateLayout(this.terminalColumns) + const ms = performance.now() - t0 + recordYogaMs(ms) + const c = getYogaCounters() + this.lastYogaCounters = { + ms, + ...c + } + } + } + + // @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks, + // but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks) + this.container = reconciler.createContainer( + this.rootNode, + ConcurrentRoot, + null, + false, + null, + 'id', + noop, + // onUncaughtError + noop, + // onCaughtError + noop, + // onRecoverableError + noop // onDefaultTransitionIndicator + ) + + if ('production' === 'development') { + reconciler.injectIntoDevTools({ + bundleType: 0, + // Reporting React DOM's version, not Ink's + // See https://github.com/facebook/react/issues/16666#issuecomment-532639905 + version: '16.13.1', + rendererPackageName: 'ink' + }) + } + } + private handleResume = () => { + if (!this.options.stdout.isTTY) { + return + } + + // Alt screen: after SIGCONT, content is stale (shell may have written + // to main screen, switching focus away) and mouse tracking was + // disabled by handleSuspend. + if (this.altScreenActive) { + this.reenterAltScreen() + + return + } + + // Main screen: start fresh to prevent clobbering terminal content + this.frontFrame = emptyFrame( + this.frontFrame.viewport.height, + this.frontFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool + ) + this.backFrame = emptyFrame( + this.backFrame.viewport.height, + this.backFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool + ) + this.log.reset() + // Physical cursor position is unknown after the shell took over during + // suspend. Clear displayCursor so the next frame's cursor preamble + // doesn't emit a relative move from a stale park position. + this.displayCursor = null + } + + // NOT debounced. A debounce opens a window where stdout.columns is NEW + // but this.terminalColumns/Yoga are OLD — any scheduleRender during that + // window (spinner, clock) makes log-update detect a width change and + // clear the screen, then the debounce fires and clears again (double + // blank→paint flicker). useVirtualScroll's height scaling already bounds + // the per-resize cost; synchronous handling keeps dimensions consistent. + private handleResize = () => { + const cols = this.options.stdout.columns || 80 + const rows = this.options.stdout.rows || 24 + + // Terminals often emit 2+ resize events for one user action (window + // settling). Same-dimension events are no-ops; skip to avoid redundant + // frame resets and renders. + if (cols === this.terminalColumns && rows === this.terminalRows) { + return + } + + this.terminalColumns = cols + this.terminalRows = rows + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + + // Alt screen: reset frame buffers so the next render repaints from + // scratch (prevFrameContaminated → every cell written, wrapped in + // BSU/ESU — old content stays visible until the new frame swaps + // atomically). Re-assert mouse tracking (some emulators reset it on + // resize). Do NOT write ENTER_ALT_SCREEN: iTerm2 treats ?1049h as a + // buffer clear even when already in alt — that's the blank flicker. + // Self-healing re-entry (if something kicked us out of alt) is handled + // by handleResume (SIGCONT) and the sleep-wake detector; resize itself + // doesn't exit alt-screen. Do NOT write ERASE_SCREEN: render() below + // can take ~80ms; erasing first leaves the screen blank that whole time. + if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) { + if (this.altScreenMouseTracking) { + this.options.stdout.write(ENABLE_MOUSE_TRACKING) + } + + this.resetFramesForAltScreen() + this.needsEraseBeforePaint = true + } + + // Re-render the React tree with updated props so the context value changes. + // React's commit phase will call onComputeLayout() to recalculate yoga layout + // with the new dimensions, then call onRender() to render the updated frame. + // We don't call scheduleRender() here because that would render before the + // layout is updated, causing a mismatch between viewport and content dimensions. + if (this.currentNode !== null) { + this.render(this.currentNode) + } + } + resolveExitPromise: () => void = () => {} + rejectExitPromise: (reason?: Error) => void = () => {} + unsubscribeExit: () => void = () => {} + + /** + * Pause Ink and hand the terminal over to an external TUI (e.g. git + * commit editor). In non-fullscreen mode this enters the alt screen; + * in fullscreen mode we're already in alt so we just clear it. + * Call `exitAlternateScreen()` when done to restore Ink. + */ + enterAlternateScreen(): void { + this.pause() + this.suspendStdin() + this.options.stdout.write( + // Disable extended key reporting first — editors that don't speak + // CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl- if + // kitty/modifyOtherKeys stays active. exitAlternateScreen re-enables. + DISABLE_KITTY_KEYBOARD + + DISABLE_MODIFY_OTHER_KEYS + + (this.altScreenMouseTracking ? DISABLE_MOUSE_TRACKING : '') + + // disable mouse (no-op if off) + (this.altScreenActive ? '' : '\x1b[?1049h') + + // enter alt (already in alt if fullscreen) + '\x1b[?1004l' + + // disable focus reporting + '\x1b[0m' + + // reset attributes + '\x1b[?25h' + + // show cursor + '\x1b[2J' + + // clear screen + '\x1b[H' // cursor home + ) + } + + /** + * Resume Ink after an external TUI handoff with a full repaint. + * In non-fullscreen mode this exits the alt screen back to main; + * in fullscreen mode we re-enter alt and clear + repaint. + * + * The re-enter matters: terminal editors (vim, nano, less) write + * smcup/rmcup (?1049h/?1049l), so even though we started in alt, + * the editor's rmcup on exit drops us to main screen. Without + * re-entering, the 2J below wipes the user's main-screen scrollback + * and subsequent renders land in main — native terminal scroll + * returns, fullscreen scroll is dead. + */ + exitAlternateScreen(): void { + this.options.stdout.write( + (this.altScreenActive ? ENTER_ALT_SCREEN : '') + + // re-enter alt — vim's rmcup dropped us to main + '\x1b[2J' + + // clear screen (now alt if fullscreen) + '\x1b[H' + + // cursor home + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + + // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE) + (this.altScreenActive ? '' : '\x1b[?1049l') + + // exit alt (non-fullscreen only) + '\x1b[?25l' // hide cursor (Ink manages) + ) + this.resumeStdin() + + if (this.altScreenActive) { + this.resetFramesForAltScreen() + } else { + this.repaint() + } + + this.resume() + // Re-enable focus reporting and extended key reporting — terminal + // editors (vim, nano, etc.) write their own modifyOtherKeys level on + // entry and reset it on exit, leaving us unable to distinguish + // ctrl+shift+ from ctrl+. Pop-before-push keeps the + // Kitty stack balanced (a well-behaved editor restores our entry, so + // without the pop we'd accumulate depth on each editor round-trip). + this.options.stdout.write( + '\x1b[?1004h' + + (supportsExtendedKeys() ? DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS : '') + ) + } + onRender() { + if (this.isUnmounted || this.isPaused) { + return + } + + // Entering a render cancels any pending drain tick — this render will + // handle the drain (and re-schedule below if needed). Prevents a + // wheel-event-triggered render AND a drain-timer render both firing. + if (this.drainTimer !== null) { + clearTimeout(this.drainTimer) + this.drainTimer = null + } + + // Flush deferred interaction-time update before rendering so we call + // Date.now() at most once per frame instead of once per keypress. + // Done before the render to avoid dirtying state that would trigger + // an extra React re-render cycle. + flushInteractionTime() + const renderStart = performance.now() + const terminalWidth = this.options.stdout.columns || 80 + const terminalRows = this.options.stdout.rows || 24 + + const frame = this.renderer({ + frontFrame: this.frontFrame, + backFrame: this.backFrame, + isTTY: this.options.stdout.isTTY, + terminalWidth, + terminalRows, + altScreen: this.altScreenActive, + prevFrameContaminated: this.prevFrameContaminated + }) + + const rendererMs = performance.now() - renderStart + + // Sticky/auto-follow scrolled the ScrollBox this frame. Translate the + // selection by the same delta so the highlight stays anchored to the + // TEXT (native terminal behavior — the selection walks up the screen + // as content scrolls, eventually clipping at the top). frontFrame + // still holds the PREVIOUS frame's screen (swap is at ~500 below), so + // captureScrolledRows reads the rows that are about to scroll out + // before they're overwritten — the text stays copyable until the + // selection scrolls entirely off. During drag, focus tracks the mouse + // (screen-local) so only anchor shifts — selection grows toward the + // mouse as the anchor walks up. After release, both ends are text- + // anchored and move as a block. + const follow = consumeFollowScroll() + + if ( + follow && + this.selection.anchor && + // Only translate if the selection is ON scrollbox content. Selections + // in the footer/prompt/StickyPromptHeader are on static text — the + // scroll doesn't move what's under them. Without this guard, a + // footer selection would be shifted by -delta then clamped to + // viewportBottom, teleporting it into the scrollbox. Mirror the + // bounds check the deleted check() in ScrollKeybindingHandler had. + this.selection.anchor.row >= follow.viewportTop && + this.selection.anchor.row <= follow.viewportBottom + ) { + const { delta, viewportTop, viewportBottom } = follow + + // captureScrolledRows and shift* are a pair: capture grabs rows about + // to scroll off, shift moves the selection endpoint so the same rows + // won't intersect again next frame. Capturing without shifting leaves + // the endpoint in place, so the SAME viewport rows re-intersect every + // frame and scrolledOffAbove grows without bound — getSelectedText + // then returns ever-growing text on each re-copy. Keep capture inside + // each shift branch so the pairing can't be broken by a new guard. + if (this.selection.isDragging) { + if (hasSelection(this.selection)) { + captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above') + } + + shiftAnchor(this.selection, -delta, viewportTop, viewportBottom) + } else if ( + // Flag-3 guard: the anchor check above only proves ONE endpoint is + // on scrollbox content. A drag from row 3 (scrollbox) into the + // footer at row 6, then release, leaves focus outside the viewport + // — shiftSelectionForFollow would clamp it to viewportBottom, + // teleporting the highlight from static footer into the scrollbox. + // Symmetric check: require BOTH ends inside to translate. A + // straddling selection falls through to NEITHER shift NOR capture: + // the footer endpoint pins the selection, text scrolls away under + // the highlight, and getSelectedText reads the CURRENT screen + // contents — no accumulation. Dragging branch doesn't need this: + // shiftAnchor ignores focus, and the anchor DOES shift (so capture + // is correct there even when focus is in the footer). + !this.selection.focus || + (this.selection.focus.row >= viewportTop && this.selection.focus.row <= viewportBottom) + ) { + if (hasSelection(this.selection)) { + captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above') + } + + const cleared = shiftSelectionForFollow(this.selection, -delta, viewportTop, viewportBottom) + + // Auto-clear (both ends overshot minRow) must notify React-land + // so useHasSelection re-renders and the footer copy/escape hint + // disappears. notifySelectionChange() would recurse into onRender; + // fire the listeners directly — they schedule a React update for + // LATER, they don't re-enter this frame. + if (cleared) { + for (const cb of this.selectionListeners) { + cb() + } + } + } + } + + // Selection overlay: invert cell styles in the screen buffer itself, + // so the diff picks up selection as ordinary cell changes and + // LogUpdate remains a pure diff engine. + // + // Full-screen damage (PR #20120) is a correctness backstop for the + // sibling-resize bleed: when flexbox siblings resize between frames + // (spinner appears → bottom grows → scrollbox shrinks), the + // cached-clear + clip-and-cull + setCellAt damage union can miss + // transition cells at the boundary. But that only happens when layout + // actually SHIFTS — didLayoutShift() tracks exactly this (any node's + // cached yoga position/size differs from current, or a child was + // removed). Steady-state frames (spinner rotate, clock tick, text + // stream into fixed-height box) don't shift layout, so normal damage + // bounds are correct and diffEach only compares the damaged region. + // + // Selection also requires full damage: overlay writes via setCellStyleId + // which doesn't track damage, and prev-frame overlay cells need to be + // compared when selection moves/clears. prevFrameContaminated covers + // the frame-after-selection-clears case. + let selActive = false + let hlActive = false + + if (this.altScreenActive) { + selActive = hasSelection(this.selection) + + if (selActive) { + applySelectionOverlay(frame.screen, this.selection, this.stylePool) + } + + // Scan-highlight: inverse on ALL visible matches (less/vim style). + // Position-highlight (below) overlays CURRENT (yellow) on top. + hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool) + + // Position-based CURRENT: write yellow at positions[currentIdx] + + // rowOffset. No scanning — positions came from a prior scan when + // the message first mounted. Message-relative + rowOffset = screen. + if (this.searchPositions) { + const sp = this.searchPositions + + const posApplied = applyPositionedHighlight( + frame.screen, + this.stylePool, + sp.positions, + sp.rowOffset, + sp.currentIdx + ) + + hlActive = hlActive || posApplied + } + } + + // Full-damage backstop: applies on BOTH alt-screen and main-screen. + // Layout shifts (spinner appears, status line resizes) can leave stale + // cells at sibling boundaries that per-node damage tracking misses. + // Selection/highlight overlays write via setCellStyleId which doesn't + // track damage. prevFrameContaminated covers the cleanup frame. + if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) { + frame.screen.damage = { + x: 0, + y: 0, + width: frame.screen.width, + height: frame.screen.height + } + } + + // Alt-screen: anchor the physical cursor to (0,0) before every diff. + // All cursor moves in log-update are RELATIVE to prev.cursor; if tmux + // (or any emulator) perturbs the physical cursor out-of-band (status + // bar refresh, pane redraw, Cmd+K wipe), the relative moves drift and + // content creeps up 1 row/frame. CSI H resets the physical cursor; + // passing prev.cursor=(0,0) makes the diff compute from the same spot. + // Self-healing against any external cursor manipulation. Main-screen + // can't do this — cursor.y tracks scrollback rows CSI H can't reach. + // The CSI H write is deferred until after the diff is computed so we + // can skip it for empty diffs (no writes → physical cursor unused). + let prevFrame = this.frontFrame + + if (this.altScreenActive) { + prevFrame = { + ...this.frontFrame, + cursor: ALT_SCREEN_ANCHOR_CURSOR + } + } + + const tDiff = performance.now() + + const diff = this.log.render( + prevFrame, + frame, + this.altScreenActive, + // DECSTBM needs BSU/ESU atomicity — without it the outer terminal + // renders the scrolled-but-not-yet-repainted intermediate state. + // tmux is the main case (re-emits DECSTBM with its own timing and + // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). + SYNC_OUTPUT_SUPPORTED + ) + + const diffMs = performance.now() - tDiff + // Swap buffers + this.backFrame = this.frontFrame + this.frontFrame = frame + + // Periodically reset char/hyperlink pools to prevent unbounded growth + // during long sessions. 5 minutes is infrequent enough that the O(cells) + // migration cost is negligible. Reuses renderStart to avoid extra clock call. + if (renderStart - this.lastPoolResetTime > 5 * 60 * 1000) { + this.resetPools() + this.lastPoolResetTime = renderStart + } + + const flickers: FrameEvent['flickers'] = [] + + for (const patch of diff) { + if (patch.type === 'clearTerminal') { + flickers.push({ + desiredHeight: frame.screen.height, + availableHeight: frame.viewport.height, + reason: patch.reason + }) + + if (isDebugRepaintsEnabled() && patch.debug) { + const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY) + logForDebugging( + `[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + + ` prev: "${patch.debug.prevLine}"\n` + + ` next: "${patch.debug.nextLine}"\n` + + ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, + { + level: 'warn' + } + ) + } + } + } + + const tOptimize = performance.now() + const optimized = optimize(diff) + const optimizeMs = performance.now() - tOptimize + const hasDiff = optimized.length > 0 + + if (this.altScreenActive && hasDiff) { + // Prepend CSI H to anchor the physical cursor to (0,0) so + // log-update's relative moves compute from a known spot (self-healing + // against out-of-band cursor drift, see the ALT_SCREEN_ANCHOR_CURSOR + // comment above). Append CSI row;1 H to park the cursor at the bottom + // row (where the prompt input is) — without this, the cursor ends + // wherever the last diff write landed (a different row every frame), + // making iTerm2's cursor guide flicker as it chases the cursor. + // BSU/ESU protects content atomicity but iTerm2's guide tracks cursor + // position independently. Parking at bottom (not 0,0) keeps the guide + // where the user's attention is. + // + // After resize, prepend ERASE_SCREEN too. The diff only writes cells + // that changed; cells where new=blank and prev-buffer=blank get skipped + // — but the physical terminal still has stale content there (shorter + // lines at new width leave old-width text tails visible). ERASE inside + // BSU/ESU is atomic: old content stays visible until the whole + // erase+paint lands, then swaps in one go. Writing ERASE_SCREEN + // synchronously in handleResize would blank the screen for the ~80ms + // render() takes. + if (this.needsEraseBeforePaint) { + this.needsEraseBeforePaint = false + optimized.unshift(ERASE_THEN_HOME_PATCH) + } else { + optimized.unshift(CURSOR_HOME_PATCH) + } + + optimized.push(this.altScreenParkPatch) + } + + // Native cursor positioning: park the terminal cursor at the declared + // position so IME preedit text renders inline and screen readers / + // magnifiers can follow the input. nodeCache holds the absolute screen + // rect populated by renderNodeToOutput this frame (including scrollTop + // translation) — if the declared node didn't render (stale declaration + // after remount, or scrolled out of view), it won't be in the cache + // and no move is emitted. + const decl = this.cursorDeclaration + const rect = decl !== null ? nodeCache.get(decl.node) : undefined + + const target = + decl !== null && rect !== undefined + ? { + x: rect.x + decl.relativeX, + y: rect.y + decl.relativeY + } + : null + + const parked = this.displayCursor + + // Preserve the empty-diff zero-write fast path: skip all cursor writes + // when nothing rendered AND the park target is unchanged. + const targetMoved = target !== null && (parked === null || parked.x !== target.x || parked.y !== target.y) + + if (hasDiff || targetMoved || (target === null && parked !== null)) { + // Main-screen preamble: log-update's relative moves assume the + // physical cursor is at prevFrame.cursor. If last frame parked it + // elsewhere, move back before the diff runs. Alt-screen's CSI H + // already resets to (0,0) so no preamble needed. + if (parked !== null && !this.altScreenActive && hasDiff) { + const pdx = prevFrame.cursor.x - parked.x + const pdy = prevFrame.cursor.y - parked.y + + if (pdx !== 0 || pdy !== 0) { + optimized.unshift({ + type: 'stdout', + content: cursorMove(pdx, pdy) + }) + } + } + + if (target !== null) { + if (this.altScreenActive) { + // Absolute CUP (1-indexed); next frame's CSI H resets regardless. + // Emitted after altScreenParkPatch so the declared position wins. + const row = Math.min(Math.max(target.y + 1, 1), terminalRows) + const col = Math.min(Math.max(target.x + 1, 1), terminalWidth) + optimized.push({ + type: 'stdout', + content: cursorPosition(row, col) + }) + } else { + // After the diff (or preamble), cursor is at frame.cursor. If no + // diff AND previously parked, it's still at the old park position + // (log-update wrote nothing). Otherwise it's at frame.cursor. + const from = + !hasDiff && parked !== null + ? parked + : { + x: frame.cursor.x, + y: frame.cursor.y + } + + const dx = target.x - from.x + const dy = target.y - from.y + + if (dx !== 0 || dy !== 0) { + optimized.push({ + type: 'stdout', + content: cursorMove(dx, dy) + }) + } + } + + this.displayCursor = target + } else { + // Declaration cleared (input blur, unmount). Restore physical cursor + // to frame.cursor before forgetting the park position — otherwise + // displayCursor=null lies about where the cursor is, and the NEXT + // frame's preamble (or log-update's relative moves) computes from a + // wrong spot. The preamble above handles hasDiff; this handles + // !hasDiff (e.g. accessibility mode where blur doesn't change + // renderedValue since invert is identity). + if (parked !== null && !this.altScreenActive && !hasDiff) { + const rdx = frame.cursor.x - parked.x + const rdy = frame.cursor.y - parked.y + + if (rdx !== 0 || rdy !== 0) { + optimized.push({ + type: 'stdout', + content: cursorMove(rdx, rdy) + }) + } + } + + this.displayCursor = null + } + } + + const tWrite = performance.now() + writeDiffToTerminal(this.terminal, optimized, this.altScreenActive && !SYNC_OUTPUT_SUPPORTED) + const writeMs = performance.now() - tWrite + + // Update blit safety for the NEXT frame. The frame just rendered + // becomes frontFrame (= next frame's prevScreen). If we applied the + // selection overlay, that buffer has inverted cells. selActive/hlActive + // are only ever true in alt-screen; in main-screen this is false→false. + this.prevFrameContaminated = selActive || hlActive + + // A ScrollBox has pendingScrollDelta left to drain — schedule the next + // frame. MUST NOT call this.scheduleRender() here: we're inside a + // trailing-edge throttle invocation, timerId is undefined, and lodash's + // debounce sees timeSinceLastCall >= wait (last call was at the start + // of this window) → leadingEdge fires IMMEDIATELY → double render ~0.1ms + // apart → jank. Use a plain timeout. If a wheel event arrives first, + // its scheduleRender path fires a render which clears this timer at + // the top of onRender — no double. + // + // Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) so run at + // quarter interval (~250fps, setTimeout practical floor) for max scroll + // speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle. + if (frame.scrollDrainPending) { + this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2) + } + + const yogaMs = getLastYogaMs() + const commitMs = getLastCommitMs() + const yc = this.lastYogaCounters + // Reset so drain-only frames (no React commit) don't repeat stale values. + resetProfileCounters() + this.lastYogaCounters = { + ms: 0, + visited: 0, + measured: 0, + cacheHits: 0, + live: 0 + } + this.options.onFrame?.({ + durationMs: performance.now() - renderStart, + phases: { + renderer: rendererMs, + diff: diffMs, + optimize: optimizeMs, + write: writeMs, + patches: diff.length, + yoga: yogaMs, + commit: commitMs, + yogaVisited: yc.visited, + yogaMeasured: yc.measured, + yogaCacheHits: yc.cacheHits, + yogaLive: yc.live + }, + flickers + }) + } + pause(): void { + // Flush pending React updates and render before pausing. + // @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler + reconciler.flushSyncFromReconciler() + this.onRender() + this.isPaused = true + } + resume(): void { + this.isPaused = false + this.onRender() + } + + /** + * Reset frame buffers so the next render writes the full screen from scratch. + * Call this before resume() when the terminal content has been corrupted by + * an external process (e.g. tmux, shell, full-screen TUI). + */ + repaint(): void { + this.frontFrame = emptyFrame( + this.frontFrame.viewport.height, + this.frontFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool + ) + this.backFrame = emptyFrame( + this.backFrame.viewport.height, + this.backFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool + ) + this.log.reset() + // Physical cursor position is unknown after external terminal corruption. + // Clear displayCursor so the cursor preamble doesn't emit a stale + // relative move from where we last parked it. + this.displayCursor = null + } + + /** + * Clear the physical terminal and force a full redraw. + * + * The traditional readline ctrl+l — clears the visible screen and + * redraws the current content. Also the recovery path when the terminal + * was cleared externally (macOS Cmd+K) and Ink's diff engine thinks + * unchanged cells don't need repainting. Scrollback is preserved. + */ + forceRedraw(): void { + if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) { + return + } + + this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME) + + if (this.altScreenActive) { + this.resetFramesForAltScreen() + } else { + this.repaint() + // repaint() resets frontFrame to 0×0. Without this flag the next + // frame's blit optimization copies from that empty screen and the + // diff sees no content. onRender resets the flag at frame end. + this.prevFrameContaminated = true + } + + this.onRender() + } + + /** + * Mark the previous frame as untrustworthy for blit, forcing the next + * render to do a full-damage diff instead of the per-node fast path. + * + * Lighter than forceRedraw() — no screen clear, no extra write. Call + * from a useLayoutEffect cleanup when unmounting a tall overlay: the + * blit fast path can copy stale cells from the overlay frame into rows + * the shrunken layout no longer reaches, leaving a ghost title/divider. + * onRender resets the flag at frame end so it's one-shot. + */ + invalidatePrevFrame(): void { + this.prevFrameContaminated = true + } + + /** + * Called by the component on mount/unmount. + * Controls cursor.y clamping in the renderer and gates alt-screen-aware + * behavior in SIGCONT/resize/unmount handlers. Repaints on change so + * the first alt-screen frame (and first main-screen frame on exit) is + * a full redraw with no stale diff state. + */ + setAltScreenActive(active: boolean, mouseTracking = false): void { + if (this.altScreenActive === active) { + return + } + + this.altScreenActive = active + this.altScreenMouseTracking = active && mouseTracking + + if (active) { + this.resetFramesForAltScreen() + } else { + this.repaint() + } + } + get isAltScreenActive(): boolean { + return this.altScreenActive + } + + /** + * Re-assert terminal modes after a gap (>5s stdin silence or event-loop + * stall). Catches tmux detach→attach, ssh reconnect, and laptop + * sleep/wake — none of which send SIGCONT. The terminal may reset DEC + * private modes on reconnect; this method restores them. + * + * Always re-asserts extended key reporting and mouse tracking. Mouse + * tracking is idempotent (DEC private mode set-when-set is a no-op). The + * Kitty keyboard protocol is NOT — CSI >1u is a stack push, so we pop + * first to keep depth balanced (pop on empty stack is a no-op per spec, + * so after a terminal reset this still restores depth 0→1). Without the + * pop, each >5s idle gap adds a stack entry, and the single pop on exit + * or suspend can't drain them — the shell is left in CSI u mode where + * Ctrl+C/Ctrl+D leak as escape sequences. The alt-screen + * re-entry (ERASE_SCREEN + frame reset) is NOT idempotent — it blanks the + * screen — so it's opt-in via includeAltScreen. The stdin-gap caller fires + * on ordinary >5s idle + keypress and must not erase; the event-loop stall + * detector fires on genuine sleep/wake and opts in. tmux attach / ssh + * reconnect typically send a resize, which already covers alt-screen via + * handleResize. + */ + reassertTerminalModes = (includeAltScreen = false): void => { + if (!this.options.stdout.isTTY) { + return + } + + // Don't touch the terminal during an editor handoff — re-enabling kitty + // keyboard here would undo enterAlternateScreen's disable and nano would + // start seeing CSI-u sequences again. + if (this.isPaused) { + return + } + + // Extended keys — re-assert if enabled (App.tsx enables these on + // allowlisted terminals at raw-mode entry; a terminal reset clears them). + // Pop-before-push keeps Kitty stack depth at 1 instead of accumulating + // on each call. + if (supportsExtendedKeys()) { + this.options.stdout.write(DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS) + } + + if (!this.altScreenActive) { + return + } + + // Mouse tracking — idempotent, safe to re-assert on every stdin gap. + if (this.altScreenMouseTracking) { + this.options.stdout.write(ENABLE_MOUSE_TRACKING) + } + + // Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that + // have a strong signal the terminal actually dropped mode 1049. + if (includeAltScreen) { + this.reenterAltScreen() + } + } + + /** + * Mark this instance as unmounted so future unmount() calls early-return. + * Called by gracefulShutdown's cleanupTerminalModes() after it has sent + * EXIT_ALT_SCREEN but before the remaining terminal-reset sequences. + * Without this, signal-exit's deferred ink.unmount() (triggered by + * process.exit()) runs the full unmount path: onRender() + writeSync + * cleanup block + updateContainerSync → AlternateScreen unmount cleanup. + * The result is 2-3 redundant EXIT_ALT_SCREEN sequences landing on the + * main screen AFTER printResumeHint(), which tmux (at least) interprets + * as restoring the saved cursor position — clobbering the resume hint. + */ + detachForShutdown(): void { + this.isUnmounted = true + // Cancel any pending throttled render so it doesn't fire between + // cleanupTerminalModes() and process.exit() and write to main screen. + this.scheduleRender.cancel?.() + + // Restore stdin from raw mode. unmount() used to do this via React + // unmount (App.componentWillUnmount → handleSetRawMode(false)) but we're + // short-circuiting that path. Must use this.options.stdin — NOT + // process.stdin — because getStdinOverride() may have opened /dev/tty + // when stdin is piped. + const stdin = this.options.stdin as NodeJS.ReadStream & { + isRaw?: boolean + setRawMode?: (m: boolean) => void + } + + this.drainStdin() + + if (stdin.isTTY && stdin.isRaw && stdin.setRawMode) { + stdin.setRawMode(false) + } + } + + /** @see drainStdin */ + drainStdin(): void { + drainStdin(this.options.stdin) + } + + /** + * Re-enter alt-screen, clear, home, re-enable mouse tracking, and reset + * frame buffers so the next render repaints from scratch. Self-heal for + * SIGCONT, resize, and stdin-gap/event-loop-stall (sleep/wake) — any of + * which can leave the terminal in main-screen mode while altScreenActive + * stays true. ENTER_ALT_SCREEN is a terminal-side no-op if already in alt. + */ + private reenterAltScreen(): void { + this.options.stdout.write( + ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + ) + this.resetFramesForAltScreen() + } + + /** + * Seed prev/back frames with full-size BLANK screens (rows×cols of empty + * cells, not 0×0). In alt-screen mode, next.screen.height is always + * terminalRows; if prev.screen.height is 0 (emptyFrame's default), + * log-update sees heightDelta > 0 ('growing') and calls renderFrameSlice, + * whose trailing per-row CR+LF at the last row scrolls the alt screen, + * permanently desyncing the virtual and physical cursors by 1 row. + * + * With a rows×cols blank prev, heightDelta === 0 → standard diffEach + * → moveCursorTo (CSI cursorMove, no LF, no scroll). + * + * viewport.height = rows + 1 matches the renderer's alt-screen output, + * preventing a spurious resize trigger on the first frame. cursor.y = 0 + * matches the physical cursor after ENTER_ALT_SCREEN + CSI H (home). + */ + private resetFramesForAltScreen(): void { + const rows = this.terminalRows + const cols = this.terminalColumns + + const blank = (): Frame => ({ + screen: createScreen(cols, rows, this.stylePool, this.charPool, this.hyperlinkPool), + viewport: { + width: cols, + height: rows + 1 + }, + cursor: { + x: 0, + y: 0, + visible: true + } + }) + + this.frontFrame = blank() + this.backFrame = blank() + this.log.reset() + // Defense-in-depth: alt-screen skips the cursor preamble anyway (CSI H + // resets), but a stale displayCursor would be misleading if we later + // exit to main-screen without an intervening render. + this.displayCursor = null + // Fresh frontFrame is blank rows×cols — blitting from it would copy + // blanks over content. Next alt-screen frame must full-render. + this.prevFrameContaminated = true + } + + /** + * Copy the current selection to the clipboard without clearing the + * highlight. Matches iTerm2's copy-on-select behavior where the selected + * region stays visible after the automatic copy. + */ + copySelectionNoClear(): string { + if (!hasSelection(this.selection)) { + return '' + } + + const text = getSelectedText(this.selection, this.frontFrame.screen) + + if (text) { + // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux + // drops it silently unless allow-passthrough is on — no regression). + void setClipboard(text).then(raw => { + if (raw) { + this.options.stdout.write(raw) + } + }) + } + + return text + } + + /** + * Copy the current text selection to the system clipboard via OSC 52 + * and clear the selection. Returns the copied text (empty if no selection). + */ + copySelection(): string { + if (!hasSelection(this.selection)) { + return '' + } + + const text = this.copySelectionNoClear() + clearSelection(this.selection) + this.notifySelectionChange() + + return text + } + + /** Clear the current text selection without copying. */ + clearTextSelection(): void { + if (!hasSelection(this.selection)) { + return + } + + clearSelection(this.selection) + this.notifySelectionChange() + } + + /** + * Set the search highlight query. Non-empty → all visible occurrences + * are inverted (SGR 7) on the next frame; first one also underlined. + * Empty → clears (prevFrameContaminated handles the frame after). Same + * damage-tracking machinery as selection — setCellStyleId doesn't track + * damage, so the overlay forces full-frame damage while active. + */ + setSearchHighlight(query: string): void { + if (this.searchHighlightQuery === query) { + return + } + + this.searchHighlightQuery = query + this.scheduleRender() + } + + /** Paint an EXISTING DOM subtree to a fresh Screen at its natural + * height, scan for query. Returns positions relative to the element's + * bounding box (row 0 = element top). + * + * The element comes from the MAIN tree — built with all real + * providers, yoga already computed. We paint it to a fresh buffer + * with offsets so it lands at (0,0). Same paint path as the main + * render. Zero drift. No second React root, no context bridge. + * + * ~1-2ms (paint only, no reconcile — the DOM is already built). */ + scanElementSubtree(el: dom.DOMElement): MatchPosition[] { + if (!this.searchHighlightQuery || !el.yogaNode) { + return [] + } + + const width = Math.ceil(el.yogaNode.getComputedWidth()) + const height = Math.ceil(el.yogaNode.getComputedHeight()) + + if (width <= 0 || height <= 0) { + return [] + } + + // renderNodeToOutput adds el's OWN computedLeft/Top to offsetX/Y. + // Passing -elLeft/-elTop nets to 0 → paints at (0,0) in our buffer. + const elLeft = el.yogaNode.getComputedLeft() + const elTop = el.yogaNode.getComputedTop() + const screen = createScreen(width, height, this.stylePool, this.charPool, this.hyperlinkPool) + + const output = new Output({ + width, + height, + stylePool: this.stylePool, + screen + }) + + renderNodeToOutput(el, output, { + offsetX: -elLeft, + offsetY: -elTop, + prevScreen: undefined + }) + const rendered = output.get() + // renderNodeToOutput wrote our offset positions to nodeCache — + // corrupts the main render (it'd blit from wrong coords). Mark the + // subtree dirty so the next main render repaints + re-caches + // correctly. One extra paint of this message, but correct > fast. + dom.markDirty(el) + const positions = scanPositions(rendered, this.searchHighlightQuery) + logForDebugging( + `scanElementSubtree: q='${this.searchHighlightQuery}' ` + + `el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` + + `[${positions + .slice(0, 10) + .map(p => `${p.row}:${p.col}`) + .join(',')}` + + `${positions.length > 10 ? ',…' : ''}]` + ) + + return positions + } + + /** Set the position-based highlight state. Every frame, writes CURRENT + * style at positions[currentIdx] + rowOffset. null clears. The scan- + * highlight (inverse on all matches) still runs — this overlays yellow + * on top. rowOffset changes as the user scrolls (= message's current + * screen-top); positions stay stable (message-relative). */ + setSearchPositions( + state: { + positions: MatchPosition[] + rowOffset: number + currentIdx: number + } | null + ): void { + this.searchPositions = state + this.scheduleRender() + } + + /** + * Set the selection highlight background color. Replaces the per-cell + * SGR-7 inverse with a solid theme-aware bg (matches native terminal + * selection). Accepts the same color formats as Text backgroundColor + * (rgb(), ansi:name, #hex, ansi256()) — colorize() routes through + * chalk so the tmux/xterm.js level clamps in colorize.ts apply and + * the emitted SGR is correct for the current terminal. + * + * Called by React-land once theme is known (ScrollKeybindingHandler's + * useEffect watching useTheme). Before that call, withSelectionBg + * falls back to withInverse so selection still renders on the first + * frame; the effect fires before any mouse input so the fallback is + * unobservable in practice. + */ + setSelectionBgColor(color: string): void { + // Wrap a NUL marker, then split on it to extract the open/close SGR. + // colorize returns the input unchanged if the color string is bad — + // no NUL-split then, so fall through to null (inverse fallback). + const wrapped = colorize('\0', color, 'background') + const nul = wrapped.indexOf('\0') + + if (nul <= 0 || nul === wrapped.length - 1) { + this.stylePool.setSelectionBg(null) + + return + } + + this.stylePool.setSelectionBg({ + type: 'ansi', + code: wrapped.slice(0, nul), + endCode: wrapped.slice(nul + 1) // always \x1b[49m for bg + }) + // No scheduleRender: this is called from a React effect that already + // runs inside the render cycle, and the bg only matters once a + // selection exists (which itself triggers a full-damage frame). + } + + /** + * Capture text from rows about to scroll out of the viewport during + * drag-to-scroll. Must be called BEFORE the ScrollBox scrolls so the + * screen buffer still holds the outgoing content. Accumulated into + * the selection state and joined back in by getSelectedText. + */ + captureScrolledRows(firstRow: number, lastRow: number, side: 'above' | 'below'): void { + captureScrolledRows(this.selection, this.frontFrame.screen, firstRow, lastRow, side) + } + + /** + * Shift anchor AND focus by dRow, clamped to [minRow, maxRow]. Used by + * keyboard scroll handlers (PgUp/PgDn etc.) so the highlight tracks the + * content instead of disappearing. Unlike shiftAnchor (drag-to-scroll), + * this moves BOTH endpoints — the user isn't holding the mouse at one + * edge. Supplies screen.width for the col-reset-on-clamp boundary. + */ + shiftSelectionForScroll(dRow: number, minRow: number, maxRow: number): void { + const hadSel = hasSelection(this.selection) + shiftSelection(this.selection, dRow, minRow, maxRow, this.frontFrame.screen.width) + + // shiftSelection clears when both endpoints overshoot the same edge + // (Home/g/End/G page-jump past the selection). Notify subscribers so + // useHasSelection updates. Safe to call notifySelectionChange here — + // this runs from keyboard handlers, not inside onRender(). + if (hadSel && !hasSelection(this.selection)) { + this.notifySelectionChange() + } + } + + /** + * Keyboard selection extension (shift+arrow/home/end). Moves focus; + * anchor stays fixed so the highlight grows or shrinks relative to it. + * Left/right wrap across row boundaries — native macOS text-edit + * behavior: shift+left at col 0 wraps to end of the previous row. + * Up/down clamp at viewport edges (no scroll-to-extend yet). Drops to + * char mode. No-op outside alt-screen or without an active selection. + */ + moveSelectionFocus(move: FocusMove): void { + if (!this.altScreenActive) { + return + } + + const { focus } = this.selection + + if (!focus) { + return + } + + const { width, height } = this.frontFrame.screen + + const maxCol = width - 1 + const maxRow = height - 1 + + let { col, row } = focus + + switch (move) { + case 'left': + if (col > 0) { + col-- + } else if (row > 0) { + col = maxCol + row-- + } + + break + + case 'right': + if (col < maxCol) { + col++ + } else if (row < maxRow) { + col = 0 + row++ + } + + break + + case 'up': + if (row > 0) { + row-- + } + + break + + case 'down': + if (row < maxRow) { + row++ + } + + break + + case 'lineStart': + col = 0 + + break + + case 'lineEnd': + col = maxCol + + break + } + + if (col === focus.col && row === focus.row) { + return + } + + moveFocus(this.selection, col, row) + this.notifySelectionChange() + } + + /** Whether there is an active text selection. */ + hasTextSelection(): boolean { + return hasSelection(this.selection) + } + + /** + * Subscribe to selection state changes. Fires whenever the selection + * is started, updated, cleared, or copied. Returns an unsubscribe fn. + */ + subscribeToSelectionChange(cb: () => void): () => void { + this.selectionListeners.add(cb) + + return () => this.selectionListeners.delete(cb) + } + private notifySelectionChange(): void { + this.onRender() + + for (const cb of this.selectionListeners) { + cb() + } + } + + /** + * Hit-test the rendered DOM tree at (col, row) and bubble a ClickEvent + * from the deepest hit node up through ancestors with onClick handlers. + * Returns true if a DOM handler consumed the click. Gated on + * altScreenActive — clicks only make sense with a fixed viewport where + * nodeCache rects map 1:1 to terminal cells (no scrollback offset). + */ + dispatchClick(col: number, row: number): boolean { + if (!this.altScreenActive) { + return false + } + + const blank = isEmptyCellAt(this.frontFrame.screen, col, row) + + return dispatchClick(this.rootNode, col, row, blank) + } + dispatchHover(col: number, row: number): void { + if (!this.altScreenActive) { + return + } + + dispatchHover(this.rootNode, col, row, this.hoveredNodes) + } + dispatchKeyboardEvent(parsedKey: ParsedKey): void { + const target = this.focusManager.activeElement ?? this.rootNode + const event = new KeyboardEvent(parsedKey) + dispatcher.dispatchDiscrete(target, event) + + // Tab cycling is the default action — only fires if no handler + // called preventDefault(). Mirrors browser behavior. + if (!event.defaultPrevented && parsedKey.name === 'tab' && !parsedKey.ctrl && !parsedKey.meta) { + if (parsedKey.shift) { + this.focusManager.focusPrevious(this.rootNode) + } else { + this.focusManager.focusNext(this.rootNode) + } + } + } + /** + * Look up the URL at (col, row) in the current front frame. Checks for + * an OSC 8 hyperlink first, then falls back to scanning the row for a + * plain-text URL (mouse tracking intercepts the terminal's native + * Cmd+Click URL detection, so we replicate it). This is a pure lookup + * with no side effects — call it synchronously at click time so the + * result reflects the screen the user actually clicked on, then defer + * the browser-open action via a timer. + */ + getHyperlinkAt(col: number, row: number): string | undefined { + if (!this.altScreenActive) { + return undefined + } + + const screen = this.frontFrame.screen + const cell = cellAt(screen, col, row) + let url = cell?.hyperlink + + // SpacerTail cells (right half of wide/CJK/emoji chars) store the + // hyperlink on the head cell at col-1. + if (!url && cell?.width === CellWidth.SpacerTail && col > 0) { + url = cellAt(screen, col - 1, row)?.hyperlink + } + + return url ?? findPlainTextUrlAt(screen, col, row) + } + + /** + * Optional callback fired when clicking an OSC 8 hyperlink in fullscreen + * mode. Set by FullscreenLayout via useLayoutEffect. + */ + onHyperlinkClick: ((url: string) => void) | undefined + + /** + * Stable prototype wrapper for onHyperlinkClick. Passed to as + * onOpenHyperlink so the prop is a bound method (autoBind'd) that reads + * the mutable field at call time — not the undefined-at-render value. + */ + openHyperlink(url: string): void { + this.onHyperlinkClick?.(url) + } + + /** + * Handle a double- or triple-click at (col, row): select the word or + * line under the cursor by reading the current screen buffer. Called on + * PRESS (not release) so the highlight appears immediately and drag can + * extend the selection word-by-word / line-by-line. Falls back to + * char-mode startSelection if the click lands on a noSelect cell. + */ + handleMultiClick(col: number, row: number, count: 2 | 3): void { + if (!this.altScreenActive) { + return + } + + const screen = this.frontFrame.screen + // selectWordAt/selectLineAt no-op on noSelect/out-of-bounds. Seed with + // a char-mode selection so the press still starts a drag even if the + // word/line scan finds nothing selectable. + startSelection(this.selection, col, row) + + if (count === 2) { + selectWordAt(this.selection, screen, col, row) + } else { + selectLineAt(this.selection, screen, row) + } + + // Ensure hasSelection is true so release doesn't re-dispatch onClickAt. + // selectWordAt no-ops on noSelect; selectLineAt no-ops out-of-bounds. + if (!this.selection.focus) { + this.selection.focus = this.selection.anchor + } + + this.notifySelectionChange() + } + + /** + * Handle a drag-motion at (col, row). In char mode updates focus to the + * exact cell. In word/line mode snaps to word/line boundaries so the + * selection extends by word/line like native macOS. Gated on + * altScreenActive for the same reason as dispatchClick. + */ + handleSelectionDrag(col: number, row: number): void { + if (!this.altScreenActive) { + return + } + + const sel = this.selection + + if (sel.anchorSpan) { + extendSelection(sel, this.frontFrame.screen, col, row) + } else { + updateSelection(sel, col, row) + } + + this.notifySelectionChange() + } + + // Methods to properly suspend stdin for external editor usage + // This is needed to prevent Ink from swallowing keystrokes when an external editor is active + private stdinListeners: Array<{ + event: string + listener: (...args: unknown[]) => void + }> = [] + private wasRawMode = false + suspendStdin(): void { + const stdin = this.options.stdin + + if (!stdin.isTTY) { + return + } + + // Store and remove all 'readable' event listeners temporarily + // This prevents Ink from consuming stdin while the editor is active + const readableListeners = stdin.listeners('readable') + logForDebugging( + `[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${ + ( + stdin as NodeJS.ReadStream & { + isRaw?: boolean + } + ).isRaw ?? false + }` + ) + readableListeners.forEach(listener => { + this.stdinListeners.push({ + event: 'readable', + listener: listener as (...args: unknown[]) => void + }) + stdin.removeListener('readable', listener as (...args: unknown[]) => void) + }) + + // If raw mode is enabled, disable it temporarily + const stdinWithRaw = stdin as NodeJS.ReadStream & { + isRaw?: boolean + setRawMode?: (mode: boolean) => void + } + + if (stdinWithRaw.isRaw && stdinWithRaw.setRawMode) { + stdinWithRaw.setRawMode(false) + this.wasRawMode = true + } + } + resumeStdin(): void { + const stdin = this.options.stdin + + if (!stdin.isTTY) { + return + } + + // Re-attach all the stored listeners + if (this.stdinListeners.length === 0 && !this.wasRawMode) { + logForDebugging('[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', { + level: 'warn' + }) + } + + logForDebugging( + `[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}` + ) + this.stdinListeners.forEach(({ event, listener }) => { + stdin.addListener(event, listener) + }) + this.stdinListeners = [] + + // Re-enable raw mode if it was enabled before + if (this.wasRawMode) { + const stdinWithRaw = stdin as NodeJS.ReadStream & { + setRawMode?: (mode: boolean) => void + } + + if (stdinWithRaw.setRawMode) { + stdinWithRaw.setRawMode(true) + } + + this.wasRawMode = false + } + } + + // Stable identity for TerminalWriteContext. An inline arrow here would + // change on every render() call (initial mount + each resize), which + // cascades through useContext → 's useLayoutEffect dep + // array → spurious exit+re-enter of the alt screen on every SIGWINCH. + private writeRaw(data: string): void { + this.options.stdout.write(data) + } + private setCursorDeclaration: CursorDeclarationSetter = (decl, clearIfNode) => { + if (decl === null && clearIfNode !== undefined && this.cursorDeclaration?.node !== clearIfNode) { + return + } + + this.cursorDeclaration = decl + } + render(node: ReactNode): void { + this.currentNode = node + + const tree = ( + + {node} + + ) + + // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler + reconciler.updateContainerSync(tree, this.container, null, noop) + // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler + reconciler.flushSyncWork() + } + unmount(error?: Error | number | null): void { + if (this.isUnmounted) { + return + } + + this.onRender() + this.unsubscribeExit() + + if (typeof this.restoreConsole === 'function') { + this.restoreConsole() + } + + this.restoreStderr?.() + this.unsubscribeTTYHandlers?.() + + // Non-TTY environments don't handle erasing ansi escapes well, so it's better to + // only render last frame of non-static output + const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame) + writeDiffToTerminal(this.terminal, optimize(diff)) + + // Clean up terminal modes synchronously before process exit. + // React's componentWillUnmount won't run in time when process.exit() is called, + // so we must reset terminal modes here to prevent escape sequence leakage. + // Use writeSync to stdout (fd 1) to ensure writes complete before exit. + // We unconditionally send all disable sequences because terminal detection + // may not work correctly (e.g., in tmux, screen) and these are no-ops on + // terminals that don't support them. + + if (this.options.stdout.isTTY) { + if (this.altScreenActive) { + // 's unmount effect won't run during signal-exit. + // Exit alt screen FIRST so other cleanup sequences go to the main screen. + writeSync(1, EXIT_ALT_SCREEN) + } + + // Disable mouse tracking — unconditional because altScreenActive can be + // stale if AlternateScreen's unmount (which flips the flag) raced a + // blocked event loop + SIGINT. No-op if tracking was never enabled. + writeSync(1, DISABLE_MOUSE_TRACKING) + // Drain stdin so in-flight mouse events don't leak to the shell + this.drainStdin() + // Disable extended key reporting (both kitty and modifyOtherKeys) + writeSync(1, DISABLE_MODIFY_OTHER_KEYS) + writeSync(1, DISABLE_KITTY_KEYBOARD) + // Disable focus events (DECSET 1004) + writeSync(1, DFE) + // Disable bracketed paste mode + writeSync(1, DBP) + // Show cursor + writeSync(1, SHOW_CURSOR) + // Clear iTerm2 progress bar + writeSync(1, CLEAR_ITERM2_PROGRESS) + + // Clear tab status (OSC 21337) so a stale dot doesn't linger + if (supportsTabStatus()) { + writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)) + } + } + + this.isUnmounted = true + + // Cancel any pending throttled renders to prevent accessing freed Yoga nodes + this.scheduleRender.cancel?.() + + if (this.drainTimer !== null) { + clearTimeout(this.drainTimer) + this.drainTimer = null + } + + // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler + reconciler.updateContainerSync(null, this.container, null, noop) + // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler + reconciler.flushSyncWork() + instances.delete(this.options.stdout) + + // Free the root yoga node, then clear its reference. Children are already + // freed by the reconciler's removeChildFromContainer; using .free() (not + // .freeRecursive()) avoids double-freeing them. + this.rootNode.yogaNode?.free() + this.rootNode.yogaNode = undefined + + if (error instanceof Error) { + this.rejectExitPromise(error) + } else { + this.resolveExitPromise() + } + } + async waitUntilExit(): Promise { + this.exitPromise ||= new Promise((resolve, reject) => { + this.resolveExitPromise = resolve + this.rejectExitPromise = reject + }) + + return this.exitPromise + } + resetLineCount(): void { + if (this.options.stdout.isTTY) { + // Swap so old front becomes back (for screen reuse), then reset front + this.backFrame = this.frontFrame + this.frontFrame = emptyFrame( + this.frontFrame.viewport.height, + this.frontFrame.viewport.width, + this.stylePool, + this.charPool, + this.hyperlinkPool + ) + this.log.reset() + // frontFrame is reset, so frame.cursor on the next render is (0,0). + // Clear displayCursor so the preamble doesn't compute a stale delta. + this.displayCursor = null + } + } + + /** + * Replace char/hyperlink pools with fresh instances to prevent unbounded + * growth during long sessions. Migrates the front frame's screen IDs into + * the new pools so diffing remains correct. The back frame doesn't need + * migration — resetScreen zeros it before any reads. + * + * Call between conversation turns or periodically. + */ + resetPools(): void { + this.charPool = new CharPool() + this.hyperlinkPool = new HyperlinkPool() + migrateScreenPools(this.frontFrame.screen, this.charPool, this.hyperlinkPool) + // Back frame's data is zeroed by resetScreen before reads, but its pool + // references are used by the renderer to intern new characters. Point + // them at the new pools so the next frame's IDs are comparable. + this.backFrame.screen.charPool = this.charPool + this.backFrame.screen.hyperlinkPool = this.hyperlinkPool + } + patchConsole(): () => void { + // biome-ignore lint/suspicious/noConsole: intentionally patching global console + const con = console + const originals: Partial> = {} + const toDebug = (...args: unknown[]) => logForDebugging(`console.log: ${format(...args)}`) + const toError = (...args: unknown[]) => logError(new Error(`console.error: ${format(...args)}`)) + + for (const m of CONSOLE_STDOUT_METHODS) { + originals[m] = con[m] + con[m] = toDebug + } + + for (const m of CONSOLE_STDERR_METHODS) { + originals[m] = con[m] + con[m] = toError + } + + originals.assert = con.assert + + con.assert = (condition: unknown, ...args: unknown[]) => { + if (!condition) { + toError(...args) + } + } + + return () => Object.assign(con, originals) + } + + /** + * Intercept process.stderr.write so stray writes (config.ts, hooks.ts, + * third-party deps) don't corrupt the alt-screen buffer. patchConsole only + * hooks console.* methods — direct stderr writes bypass it, land at the + * parked cursor, scroll the alt-screen, and desync frontFrame from the + * physical terminal. Next diff writes only changed-in-React cells at + * absolute coords → interleaved garbage. + * + * Swallows the write (routes text to the debug log) and, in alt-screen, + * forces a full-damage repaint as a defensive recovery. Not patching + * process.stdout — Ink itself writes there. + */ + private patchStderr(): () => void { + const stderr = process.stderr + const originalWrite = stderr.write + let reentered = false + + const intercept = ( + chunk: Uint8Array | string, + encodingOrCb?: BufferEncoding | ((err?: Error) => void), + cb?: (err?: Error) => void + ): boolean => { + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb + + // Reentrancy guard: logForDebugging → writeToStderr → here. Pass + // through to the original so --debug-to-stderr still works and we + // don't stack-overflow. + if (reentered) { + const encoding = typeof encodingOrCb === 'string' ? encodingOrCb : undefined + + return originalWrite.call(stderr, chunk, encoding, callback) + } + + reentered = true + + try { + const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8') + logForDebugging(`[stderr] ${text}`, { + level: 'warn' + }) + + if (this.altScreenActive && !this.isUnmounted && !this.isPaused) { + this.prevFrameContaminated = true + this.scheduleRender() + } + } finally { + reentered = false + callback?.() + } + + return true + } + + stderr.write = intercept + + return () => { + if (stderr.write === intercept) { + stderr.write = originalWrite + } + } + } +} + +/** + * Discard pending stdin bytes so in-flight escape sequences (mouse tracking + * reports, bracketed-paste markers) don't leak to the shell after exit. + * + * Two layers of trickiness: + * + * 1. setRawMode is termios, not fcntl — the stdin fd stays blocking, so + * readSync on it would hang forever. Node doesn't expose fcntl, so we + * open /dev/tty fresh with O_NONBLOCK (all fds to the controlling + * terminal share one line-discipline input queue). + * + * 2. By the time forceExit calls this, detachForShutdown has already put + * the TTY back in cooked (canonical) mode. Canonical mode line-buffers + * input until newline, so O_NONBLOCK reads return EAGAIN even when + * mouse bytes are sitting in the buffer. We briefly re-enter raw mode + * so reads return any available bytes, then restore cooked mode. + * + * Safe to call multiple times. Call as LATE as possible in the exit path: + * DISABLE_MOUSE_TRACKING has terminal round-trip latency, so events can + * arrive for a few ms after it's written. + */ + +export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { + if (!stdin.isTTY) { + return + } + + // Drain Node's stream buffer (bytes libuv already pulled in). read() + // returns null when empty — never blocks. + try { + while (stdin.read() !== null) { + /* discard */ + } + } catch { + /* stream may be destroyed */ + } + + // No /dev/tty on Windows; CONIN$ doesn't support O_NONBLOCK semantics. + // Windows Terminal also doesn't buffer mouse reports the same way. + if (process.platform === 'win32') { + return + } + + // termios is per-device: flip stdin to raw so canonical-mode line + // buffering doesn't hide partial input from the non-blocking read. + // Restored in the finally block. + const tty = stdin as NodeJS.ReadStream & { + isRaw?: boolean + setRawMode?: (raw: boolean) => void + } + + const wasRaw = tty.isRaw === true + // Drain the kernel TTY buffer via a fresh O_NONBLOCK fd. Bounded at 64 + // reads (64KB) — a real mouse burst is a few hundred bytes; the cap + // guards against a terminal that ignores O_NONBLOCK. + let fd = -1 + + try { + // setRawMode inside try: on revoked TTY (SIGHUP/SSH disconnect) the + // ioctl throws EBADF — same recovery path as openSync/readSync below. + if (!wasRaw) { + tty.setRawMode?.(true) + } + + fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) + const buf = Buffer.alloc(1024) + + for (let i = 0; i < 64; i++) { + if (readSync(fd, buf, 0, buf.length, null) <= 0) { + break + } + } + } catch { + // EAGAIN (buffer empty — expected), ENXIO/ENOENT (no controlling tty), + // EBADF/EIO (TTY revoked — SIGHUP, SSH disconnect) + } finally { + if (fd >= 0) { + try { + closeSync(fd) + } catch { + /* ignore */ + } + } + + if (!wasRaw) { + try { + tty.setRawMode?.(false) + } catch { + /* TTY may be gone */ + } + } + } +} + +const CONSOLE_STDOUT_METHODS = [ + 'log', + 'info', + 'debug', + 'dir', + 'dirxml', + 'count', + 'countReset', + 'group', + 'groupCollapsed', + 'groupEnd', + 'table', + 'time', + 'timeEnd', + 'timeLog' +] as const + +const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const +//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJhdXRvQmluZCIsImNsb3NlU3luYyIsImNvbnN0YW50cyIsImZzQ29uc3RhbnRzIiwib3BlblN5bmMiLCJyZWFkU3luYyIsIndyaXRlU3luYyIsIm5vb3AiLCJ0aHJvdHRsZSIsIlJlYWN0IiwiUmVhY3ROb2RlIiwiRmliZXJSb290IiwiQ29uY3VycmVudFJvb3QiLCJvbkV4aXQiLCJmbHVzaEludGVyYWN0aW9uVGltZSIsImdldFlvZ2FDb3VudGVycyIsImxvZ0ZvckRlYnVnZ2luZyIsImxvZ0Vycm9yIiwiZm9ybWF0IiwiY29sb3JpemUiLCJBcHAiLCJDdXJzb3JEZWNsYXJhdGlvbiIsIkN1cnNvckRlY2xhcmF0aW9uU2V0dGVyIiwiRlJBTUVfSU5URVJWQUxfTVMiLCJkb20iLCJLZXlib2FyZEV2ZW50IiwiRm9jdXNNYW5hZ2VyIiwiZW1wdHlGcmFtZSIsIkZyYW1lIiwiRnJhbWVFdmVudCIsImRpc3BhdGNoQ2xpY2siLCJkaXNwYXRjaEhvdmVyIiwiaW5zdGFuY2VzIiwiTG9nVXBkYXRlIiwibm9kZUNhY2hlIiwib3B0aW1pemUiLCJPdXRwdXQiLCJQYXJzZWRLZXkiLCJyZWNvbmNpbGVyIiwiZGlzcGF0Y2hlciIsImdldExhc3RDb21taXRNcyIsImdldExhc3RZb2dhTXMiLCJpc0RlYnVnUmVwYWludHNFbmFibGVkIiwicmVjb3JkWW9nYU1zIiwicmVzZXRQcm9maWxlQ291bnRlcnMiLCJyZW5kZXJOb2RlVG9PdXRwdXQiLCJjb25zdW1lRm9sbG93U2Nyb2xsIiwiZGlkTGF5b3V0U2hpZnQiLCJhcHBseVBvc2l0aW9uZWRIaWdobGlnaHQiLCJNYXRjaFBvc2l0aW9uIiwic2NhblBvc2l0aW9ucyIsImNyZWF0ZVJlbmRlcmVyIiwiUmVuZGVyZXIiLCJDZWxsV2lkdGgiLCJDaGFyUG9vbCIsImNlbGxBdCIsImNyZWF0ZVNjcmVlbiIsIkh5cGVybGlua1Bvb2wiLCJpc0VtcHR5Q2VsbEF0IiwibWlncmF0ZVNjcmVlblBvb2xzIiwiU3R5bGVQb29sIiwiYXBwbHlTZWFyY2hIaWdobGlnaHQiLCJhcHBseVNlbGVjdGlvbk92ZXJsYXkiLCJjYXB0dXJlU2Nyb2xsZWRSb3dzIiwiY2xlYXJTZWxlY3Rpb24iLCJjcmVhdGVTZWxlY3Rpb25TdGF0ZSIsImV4dGVuZFNlbGVjdGlvbiIsIkZvY3VzTW92ZSIsImZpbmRQbGFpblRleHRVcmxBdCIsImdldFNlbGVjdGVkVGV4dCIsImhhc1NlbGVjdGlvbiIsIm1vdmVGb2N1cyIsIlNlbGVjdGlvblN0YXRlIiwic2VsZWN0TGluZUF0Iiwic2VsZWN0V29yZEF0Iiwic2hpZnRBbmNob3IiLCJzaGlmdFNlbGVjdGlvbiIsInNoaWZ0U2VsZWN0aW9uRm9yRm9sbG93Iiwic3RhcnRTZWxlY3Rpb24iLCJ1cGRhdGVTZWxlY3Rpb24iLCJTWU5DX09VVFBVVF9TVVBQT1JURUQiLCJzdXBwb3J0c0V4dGVuZGVkS2V5cyIsIlRlcm1pbmFsIiwid3JpdGVEaWZmVG9UZXJtaW5hbCIsIkNVUlNPUl9IT01FIiwiY3Vyc29yTW92ZSIsImN1cnNvclBvc2l0aW9uIiwiRElTQUJMRV9LSVRUWV9LRVlCT0FSRCIsIkRJU0FCTEVfTU9ESUZZX09USEVSX0tFWVMiLCJFTkFCTEVfS0lUVFlfS0VZQk9BUkQiLCJFTkFCTEVfTU9ESUZZX09USEVSX0tFWVMiLCJFUkFTRV9TQ1JFRU4iLCJEQlAiLCJERkUiLCJESVNBQkxFX01PVVNFX1RSQUNLSU5HIiwiRU5BQkxFX01PVVNFX1RSQUNLSU5HIiwiRU5URVJfQUxUX1NDUkVFTiIsIkVYSVRfQUxUX1NDUkVFTiIsIlNIT1dfQ1VSU09SIiwiQ0xFQVJfSVRFUk0yX1BST0dSRVNTIiwiQ0xFQVJfVEFCX1NUQVRVUyIsInNldENsaXBib2FyZCIsInN1cHBvcnRzVGFiU3RhdHVzIiwid3JhcEZvck11bHRpcGxleGVyIiwiVGVybWluYWxXcml0ZVByb3ZpZGVyIiwiQUxUX1NDUkVFTl9BTkNIT1JfQ1VSU09SIiwiT2JqZWN0IiwiZnJlZXplIiwieCIsInkiLCJ2aXNpYmxlIiwiQ1VSU09SX0hPTUVfUEFUQ0giLCJ0eXBlIiwiY29uc3QiLCJjb250ZW50IiwiRVJBU0VfVEhFTl9IT01FX1BBVENIIiwibWFrZUFsdFNjcmVlblBhcmtQYXRjaCIsInRlcm1pbmFsUm93cyIsIk9wdGlvbnMiLCJzdGRvdXQiLCJOb2RlSlMiLCJXcml0ZVN0cmVhbSIsInN0ZGluIiwiUmVhZFN0cmVhbSIsInN0ZGVyciIsImV4aXRPbkN0cmxDIiwicGF0Y2hDb25zb2xlIiwid2FpdFVudGlsRXhpdCIsIlByb21pc2UiLCJvbkZyYW1lIiwiZXZlbnQiLCJJbmsiLCJsb2ciLCJ0ZXJtaW5hbCIsInNjaGVkdWxlUmVuZGVyIiwiY2FuY2VsIiwiaXNVbm1vdW50ZWQiLCJpc1BhdXNlZCIsImNvbnRhaW5lciIsInJvb3ROb2RlIiwiRE9NRWxlbWVudCIsImZvY3VzTWFuYWdlciIsInJlbmRlcmVyIiwic3R5bGVQb29sIiwiY2hhclBvb2wiLCJoeXBlcmxpbmtQb29sIiwiZXhpdFByb21pc2UiLCJyZXN0b3JlQ29uc29sZSIsInJlc3RvcmVTdGRlcnIiLCJ1bnN1YnNjcmliZVRUWUhhbmRsZXJzIiwidGVybWluYWxDb2x1bW5zIiwiY3VycmVudE5vZGUiLCJmcm9udEZyYW1lIiwiYmFja0ZyYW1lIiwibGFzdFBvb2xSZXNldFRpbWUiLCJwZXJmb3JtYW5jZSIsIm5vdyIsImRyYWluVGltZXIiLCJSZXR1cm5UeXBlIiwic2V0VGltZW91dCIsImxhc3RZb2dhQ291bnRlcnMiLCJtcyIsInZpc2l0ZWQiLCJtZWFzdXJlZCIsImNhY2hlSGl0cyIsImxpdmUiLCJhbHRTY3JlZW5QYXJrUGF0Y2giLCJSZWFkb25seSIsInNlbGVjdGlvbiIsInNlYXJjaEhpZ2hsaWdodFF1ZXJ5Iiwic2VhcmNoUG9zaXRpb25zIiwicG9zaXRpb25zIiwicm93T2Zmc2V0IiwiY3VycmVudElkeCIsInNlbGVjdGlvbkxpc3RlbmVycyIsIlNldCIsImhvdmVyZWROb2RlcyIsImFsdFNjcmVlbkFjdGl2ZSIsImFsdFNjcmVlbk1vdXNlVHJhY2tpbmciLCJwcmV2RnJhbWVDb250YW1pbmF0ZWQiLCJuZWVkc0VyYXNlQmVmb3JlUGFpbnQiLCJjdXJzb3JEZWNsYXJhdGlvbiIsImRpc3BsYXlDdXJzb3IiLCJjb25zdHJ1Y3RvciIsIm9wdGlvbnMiLCJwYXRjaFN0ZGVyciIsImNvbHVtbnMiLCJyb3dzIiwiaXNUVFkiLCJkZWZlcnJlZFJlbmRlciIsInF1ZXVlTWljcm90YXNrIiwib25SZW5kZXIiLCJsZWFkaW5nIiwidHJhaWxpbmciLCJ1bnN1YnNjcmliZUV4aXQiLCJ1bm1vdW50IiwiYWx3YXlzTGFzdCIsIm9uIiwiaGFuZGxlUmVzaXplIiwicHJvY2VzcyIsImhhbmRsZVJlc3VtZSIsIm9mZiIsImNyZWF0ZU5vZGUiLCJ0YXJnZXQiLCJkaXNwYXRjaERpc2NyZXRlIiwib25JbW1lZGlhdGVSZW5kZXIiLCJvbkNvbXB1dGVMYXlvdXQiLCJ5b2dhTm9kZSIsInQwIiwic2V0V2lkdGgiLCJjYWxjdWxhdGVMYXlvdXQiLCJjIiwiY3JlYXRlQ29udGFpbmVyIiwiaW5qZWN0SW50b0RldlRvb2xzIiwiYnVuZGxlVHlwZSIsInZlcnNpb24iLCJyZW5kZXJlclBhY2thZ2VOYW1lIiwicmVlbnRlckFsdFNjcmVlbiIsInZpZXdwb3J0IiwiaGVpZ2h0Iiwid2lkdGgiLCJyZXNldCIsImNvbHMiLCJ3cml0ZSIsInJlc2V0RnJhbWVzRm9yQWx0U2NyZWVuIiwicmVuZGVyIiwicmVzb2x2ZUV4aXRQcm9taXNlIiwicmVqZWN0RXhpdFByb21pc2UiLCJyZWFzb24iLCJFcnJvciIsImVudGVyQWx0ZXJuYXRlU2NyZWVuIiwicGF1c2UiLCJzdXNwZW5kU3RkaW4iLCJleGl0QWx0ZXJuYXRlU2NyZWVuIiwicmVzdW1lU3RkaW4iLCJyZXBhaW50IiwicmVzdW1lIiwiY2xlYXJUaW1lb3V0IiwicmVuZGVyU3RhcnQiLCJ0ZXJtaW5hbFdpZHRoIiwiZnJhbWUiLCJhbHRTY3JlZW4iLCJyZW5kZXJlck1zIiwiZm9sbG93IiwiYW5jaG9yIiwicm93Iiwidmlld3BvcnRUb3AiLCJ2aWV3cG9ydEJvdHRvbSIsImRlbHRhIiwiaXNEcmFnZ2luZyIsInNjcmVlbiIsImZvY3VzIiwiY2xlYXJlZCIsImNiIiwic2VsQWN0aXZlIiwiaGxBY3RpdmUiLCJzcCIsInBvc0FwcGxpZWQiLCJkYW1hZ2UiLCJwcmV2RnJhbWUiLCJjdXJzb3IiLCJ0RGlmZiIsImRpZmYiLCJkaWZmTXMiLCJyZXNldFBvb2xzIiwiZmxpY2tlcnMiLCJwYXRjaCIsInB1c2giLCJkZXNpcmVkSGVpZ2h0IiwiYXZhaWxhYmxlSGVpZ2h0IiwiZGVidWciLCJjaGFpbiIsImZpbmRPd25lckNoYWluQXRSb3ciLCJ0cmlnZ2VyWSIsInByZXZMaW5lIiwibmV4dExpbmUiLCJsZW5ndGgiLCJqb2luIiwibGV2ZWwiLCJ0T3B0aW1pemUiLCJvcHRpbWl6ZWQiLCJvcHRpbWl6ZU1zIiwiaGFzRGlmZiIsInVuc2hpZnQiLCJkZWNsIiwicmVjdCIsImdldCIsIm5vZGUiLCJ1bmRlZmluZWQiLCJyZWxhdGl2ZVgiLCJyZWxhdGl2ZVkiLCJwYXJrZWQiLCJ0YXJnZXRNb3ZlZCIsInBkeCIsInBkeSIsIk1hdGgiLCJtaW4iLCJtYXgiLCJjb2wiLCJmcm9tIiwiZHgiLCJkeSIsInJkeCIsInJkeSIsInRXcml0ZSIsIndyaXRlTXMiLCJzY3JvbGxEcmFpblBlbmRpbmciLCJ5b2dhTXMiLCJjb21taXRNcyIsInljIiwiZHVyYXRpb25NcyIsInBoYXNlcyIsInBhdGNoZXMiLCJ5b2dhIiwiY29tbWl0IiwieW9nYVZpc2l0ZWQiLCJ5b2dhTWVhc3VyZWQiLCJ5b2dhQ2FjaGVIaXRzIiwieW9nYUxpdmUiLCJmbHVzaFN5bmNGcm9tUmVjb25jaWxlciIsImZvcmNlUmVkcmF3IiwiaW52YWxpZGF0ZVByZXZGcmFtZSIsInNldEFsdFNjcmVlbkFjdGl2ZSIsImFjdGl2ZSIsIm1vdXNlVHJhY2tpbmciLCJpc0FsdFNjcmVlbkFjdGl2ZSIsInJlYXNzZXJ0VGVybWluYWxNb2RlcyIsImluY2x1ZGVBbHRTY3JlZW4iLCJkZXRhY2hGb3JTaHV0ZG93biIsImlzUmF3Iiwic2V0UmF3TW9kZSIsIm0iLCJkcmFpblN0ZGluIiwiYmxhbmsiLCJjb3B5U2VsZWN0aW9uTm9DbGVhciIsInRleHQiLCJ0aGVuIiwicmF3IiwiY29weVNlbGVjdGlvbiIsIm5vdGlmeVNlbGVjdGlvbkNoYW5nZSIsImNsZWFyVGV4dFNlbGVjdGlvbiIsInNldFNlYXJjaEhpZ2hsaWdodCIsInF1ZXJ5Iiwic2NhbkVsZW1lbnRTdWJ0cmVlIiwiZWwiLCJjZWlsIiwiZ2V0Q29tcHV0ZWRXaWR0aCIsImdldENvbXB1dGVkSGVpZ2h0IiwiZWxMZWZ0IiwiZ2V0Q29tcHV0ZWRMZWZ0IiwiZWxUb3AiLCJnZXRDb21wdXRlZFRvcCIsIm91dHB1dCIsIm9mZnNldFgiLCJvZmZzZXRZIiwicHJldlNjcmVlbiIsInJlbmRlcmVkIiwibWFya0RpcnR5Iiwic2xpY2UiLCJtYXAiLCJwIiwic2V0U2VhcmNoUG9zaXRpb25zIiwic3RhdGUiLCJzZXRTZWxlY3Rpb25CZ0NvbG9yIiwiY29sb3IiLCJ3cmFwcGVkIiwibnVsIiwiaW5kZXhPZiIsInNldFNlbGVjdGlvbkJnIiwiY29kZSIsImVuZENvZGUiLCJmaXJzdFJvdyIsImxhc3RSb3ciLCJzaWRlIiwic2hpZnRTZWxlY3Rpb25Gb3JTY3JvbGwiLCJkUm93IiwibWluUm93IiwibWF4Um93IiwiaGFkU2VsIiwibW92ZVNlbGVjdGlvbkZvY3VzIiwibW92ZSIsIm1heENvbCIsImhhc1RleHRTZWxlY3Rpb24iLCJzdWJzY3JpYmVUb1NlbGVjdGlvbkNoYW5nZSIsImFkZCIsImRlbGV0ZSIsImRpc3BhdGNoS2V5Ym9hcmRFdmVudCIsInBhcnNlZEtleSIsImFjdGl2ZUVsZW1lbnQiLCJkZWZhdWx0UHJldmVudGVkIiwibmFtZSIsImN0cmwiLCJtZXRhIiwic2hpZnQiLCJmb2N1c1ByZXZpb3VzIiwiZm9jdXNOZXh0IiwiZ2V0SHlwZXJsaW5rQXQiLCJjZWxsIiwidXJsIiwiaHlwZXJsaW5rIiwiU3BhY2VyVGFpbCIsIm9uSHlwZXJsaW5rQ2xpY2siLCJvcGVuSHlwZXJsaW5rIiwiaGFuZGxlTXVsdGlDbGljayIsImNvdW50IiwiaGFuZGxlU2VsZWN0aW9uRHJhZyIsInNlbCIsImFuY2hvclNwYW4iLCJzdGRpbkxpc3RlbmVycyIsIkFycmF5IiwibGlzdGVuZXIiLCJhcmdzIiwid2FzUmF3TW9kZSIsInJlYWRhYmxlTGlzdGVuZXJzIiwibGlzdGVuZXJzIiwiZm9yRWFjaCIsInJlbW92ZUxpc3RlbmVyIiwic3RkaW5XaXRoUmF3IiwibW9kZSIsImFkZExpc3RlbmVyIiwid3JpdGVSYXciLCJkYXRhIiwic2V0Q3Vyc29yRGVjbGFyYXRpb24iLCJjbGVhcklmTm9kZSIsInRyZWUiLCJ1cGRhdGVDb250YWluZXJTeW5jIiwiZmx1c2hTeW5jV29yayIsImVycm9yIiwicmVuZGVyUHJldmlvdXNPdXRwdXRfREVQUkVDQVRFRCIsImZyZWUiLCJyZXNvbHZlIiwicmVqZWN0IiwicmVzZXRMaW5lQ291bnQiLCJjb24iLCJjb25zb2xlIiwib3JpZ2luYWxzIiwiUGFydGlhbCIsIlJlY29yZCIsIkNvbnNvbGUiLCJ0b0RlYnVnIiwidG9FcnJvciIsIkNPTlNPTEVfU1RET1VUX01FVEhPRFMiLCJDT05TT0xFX1NUREVSUl9NRVRIT0RTIiwiYXNzZXJ0IiwiY29uZGl0aW9uIiwiYXNzaWduIiwib3JpZ2luYWxXcml0ZSIsInJlZW50ZXJlZCIsImludGVyY2VwdCIsImNodW5rIiwiVWludDhBcnJheSIsImVuY29kaW5nT3JDYiIsIkJ1ZmZlckVuY29kaW5nIiwiZXJyIiwiY2FsbGJhY2siLCJlbmNvZGluZyIsImNhbGwiLCJCdWZmZXIiLCJ0b1N0cmluZyIsInJlYWQiLCJwbGF0Zm9ybSIsInR0eSIsIndhc1JhdyIsImZkIiwiT19SRE9OTFkiLCJPX05PTkJMT0NLIiwiYnVmIiwiYWxsb2MiLCJpIl0sInNvdXJjZXMiOlsiaW5rLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgYXV0b0JpbmQgZnJvbSAnYXV0by1iaW5kJ1xuaW1wb3J0IHtcbiAgY2xvc2VTeW5jLFxuICBjb25zdGFudHMgYXMgZnNDb25zdGFudHMsXG4gIG9wZW5TeW5jLFxuICByZWFkU3luYyxcbiAgd3JpdGVTeW5jLFxufSBmcm9tICdmcydcbmltcG9ydCBub29wIGZyb20gJ2xvZGFzaC1lcy9ub29wLmpzJ1xuaW1wb3J0IHRocm90dGxlIGZyb20gJ2xvZGFzaC1lcy90aHJvdHRsZS5qcydcbmltcG9ydCBSZWFjdCwgeyB0eXBlIFJlYWN0Tm9kZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHR5cGUgeyBGaWJlclJvb3QgfSBmcm9tICdyZWFjdC1yZWNvbmNpbGVyJ1xuaW1wb3J0IHsgQ29uY3VycmVudFJvb3QgfSBmcm9tICdyZWFjdC1yZWNvbmNpbGVyL2NvbnN0YW50cy5qcydcbmltcG9ydCB7IG9uRXhpdCB9IGZyb20gJ3NpZ25hbC1leGl0J1xuaW1wb3J0IHsgZmx1c2hJbnRlcmFjdGlvblRpbWUgfSBmcm9tICdzcmMvYm9vdHN0cmFwL3N0YXRlLmpzJ1xuaW1wb3J0IHsgZ2V0WW9nYUNvdW50ZXJzIH0gZnJvbSAnc3JjL25hdGl2ZS10cy95b2dhLWxheW91dC9pbmRleC5qcydcbmltcG9ydCB7IGxvZ0ZvckRlYnVnZ2luZyB9IGZyb20gJ3NyYy91dGlscy9kZWJ1Zy5qcydcbmltcG9ydCB7IGxvZ0Vycm9yIH0gZnJvbSAnc3JjL3V0aWxzL2xvZy5qcydcbmltcG9ydCB7IGZvcm1hdCB9IGZyb20gJ3V0aWwnXG5pbXBvcnQgeyBjb2xvcml6ZSB9IGZyb20gJy4vY29sb3JpemUuanMnXG5pbXBvcnQgQXBwIGZyb20gJy4vY29tcG9uZW50cy9BcHAuanMnXG5pbXBvcnQgdHlwZSB7XG4gIEN1cnNvckRlY2xhcmF0aW9uLFxuICBDdXJzb3JEZWNsYXJhdGlvblNldHRlcixcbn0gZnJvbSAnLi9jb21wb25lbnRzL0N1cnNvckRlY2xhcmF0aW9uQ29udGV4dC5qcydcbmltcG9ydCB7IEZSQU1FX0lOVEVSVkFMX01TIH0gZnJvbSAnLi9jb25zdGFudHMuanMnXG5pbXBvcnQgKiBhcyBkb20gZnJvbSAnLi9kb20uanMnXG5pbXBvcnQgeyBLZXlib2FyZEV2ZW50IH0gZnJvbSAnLi9ldmVudHMva2V5Ym9hcmQtZXZlbnQuanMnXG5pbXBvcnQgeyBGb2N1c01hbmFnZXIgfSBmcm9tICcuL2ZvY3VzLmpzJ1xuaW1wb3J0IHsgZW1wdHlGcmFtZSwgdHlwZSBGcmFtZSwgdHlwZSBGcmFtZUV2ZW50IH0gZnJvbSAnLi9mcmFtZS5qcydcbmltcG9ydCB7IGRpc3BhdGNoQ2xpY2ssIGRpc3BhdGNoSG92ZXIgfSBmcm9tICcuL2hpdC10ZXN0LmpzJ1xuaW1wb3J0IGluc3RhbmNlcyBmcm9tICcuL2luc3RhbmNlcy5qcydcbmltcG9ydCB7IExvZ1VwZGF0ZSB9IGZyb20gJy4vbG9nLXVwZGF0ZS5qcydcbmltcG9ydCB7IG5vZGVDYWNoZSB9IGZyb20gJy4vbm9kZS1jYWNoZS5qcydcbmltcG9ydCB7IG9wdGltaXplIH0gZnJvbSAnLi9vcHRpbWl6ZXIuanMnXG5pbXBvcnQgT3V0cHV0IGZyb20gJy4vb3V0cHV0LmpzJ1xuaW1wb3J0IHR5cGUgeyBQYXJzZWRLZXkgfSBmcm9tICcuL3BhcnNlLWtleXByZXNzLmpzJ1xuaW1wb3J0IHJlY29uY2lsZXIsIHtcbiAgZGlzcGF0Y2hlcixcbiAgZ2V0TGFzdENvbW1pdE1zLFxuICBnZXRMYXN0WW9nYU1zLFxuICBpc0RlYnVnUmVwYWludHNFbmFibGVkLFxuICByZWNvcmRZb2dhTXMsXG4gIHJlc2V0UHJvZmlsZUNvdW50ZXJzLFxufSBmcm9tICcuL3JlY29uY2lsZXIuanMnXG5pbXBvcnQgcmVuZGVyTm9kZVRvT3V0cHV0LCB7XG4gIGNvbnN1bWVGb2xsb3dTY3JvbGwsXG4gIGRpZExheW91dFNoaWZ0LFxufSBmcm9tICcuL3JlbmRlci1ub2RlLXRvLW91dHB1dC5qcydcbmltcG9ydCB7XG4gIGFwcGx5UG9zaXRpb25lZEhpZ2hsaWdodCxcbiAgdHlwZSBNYXRjaFBvc2l0aW9uLFxuICBzY2FuUG9zaXRpb25zLFxufSBmcm9tICcuL3JlbmRlci10by1zY3JlZW4uanMnXG5pbXBvcnQgY3JlYXRlUmVuZGVyZXIsIHsgdHlwZSBSZW5kZXJlciB9IGZyb20gJy4vcmVuZGVyZXIuanMnXG5pbXBvcnQge1xuICBDZWxsV2lkdGgsXG4gIENoYXJQb29sLFxuICBjZWxsQXQsXG4gIGNyZWF0ZVNjcmVlbixcbiAgSHlwZXJsaW5rUG9vbCxcbiAgaXNFbXB0eUNlbGxBdCxcbiAgbWlncmF0ZVNjcmVlblBvb2xzLFxuICBTdHlsZVBvb2wsXG59IGZyb20gJy4vc2NyZWVuLmpzJ1xuaW1wb3J0IHsgYXBwbHlTZWFyY2hIaWdobGlnaHQgfSBmcm9tICcuL3NlYXJjaEhpZ2hsaWdodC5qcydcbmltcG9ydCB7XG4gIGFwcGx5U2VsZWN0aW9uT3ZlcmxheSxcbiAgY2FwdHVyZVNjcm9sbGVkUm93cyxcbiAgY2xlYXJTZWxlY3Rpb24sXG4gIGNyZWF0ZVNlbGVjdGlvblN0YXRlLFxuICBleHRlbmRTZWxlY3Rpb24sXG4gIHR5cGUgRm9jdXNNb3ZlLFxuICBmaW5kUGxhaW5UZXh0VXJsQXQsXG4gIGdldFNlbGVjdGVkVGV4dCxcbiAgaGFzU2VsZWN0aW9uLFxuICBtb3ZlRm9jdXMsXG4gIHR5cGUgU2VsZWN0aW9uU3RhdGUsXG4gIHNlbGVjdExpbmVBdCxcbiAgc2VsZWN0V29yZEF0LFxuICBzaGlmdEFuY2hvcixcbiAgc2hpZnRTZWxlY3Rpb24sXG4gIHNoaWZ0U2VsZWN0aW9uRm9yRm9sbG93LFxuICBzdGFydFNlbGVjdGlvbixcbiAgdXBkYXRlU2VsZWN0aW9uLFxufSBmcm9tICcuL3NlbGVjdGlvbi5qcydcbmltcG9ydCB7XG4gIFNZTkNfT1VUUFVUX1NVUFBPUlRFRCxcbiAgc3VwcG9ydHNFeHRlbmRlZEtleXMsXG4gIHR5cGUgVGVybWluYWwsXG4gIHdyaXRlRGlmZlRvVGVybWluYWwsXG59IGZyb20gJy4vdGVybWluYWwuanMnXG5pbXBvcnQge1xuICBDVVJTT1JfSE9NRSxcbiAgY3Vyc29yTW92ZSxcbiAgY3Vyc29yUG9zaXRpb24sXG4gIERJU0FCTEVfS0lUVFlfS0VZQk9BUkQsXG4gIERJU0FCTEVfTU9ESUZZX09USEVSX0tFWVMsXG4gIEVOQUJMRV9LSVRUWV9LRVlCT0FSRCxcbiAgRU5BQkxFX01PRElGWV9PVEhFUl9LRVlTLFxuICBFUkFTRV9TQ1JFRU4sXG59IGZyb20gJy4vdGVybWlvL2NzaS5qcydcbmltcG9ydCB7XG4gIERCUCxcbiAgREZFLFxuICBESVNBQkxFX01PVVNFX1RSQUNLSU5HLFxuICBFTkFCTEVfTU9VU0VfVFJBQ0tJTkcsXG4gIEVOVEVSX0FMVF9TQ1JFRU4sXG4gIEVYSVRfQUxUX1NDUkVFTixcbiAgU0hPV19DVVJTT1IsXG59IGZyb20gJy4vdGVybWlvL2RlYy5qcydcbmltcG9ydCB7XG4gIENMRUFSX0lURVJNMl9QUk9HUkVTUyxcbiAgQ0xFQVJfVEFCX1NUQVRVUyxcbiAgc2V0Q2xpcGJvYXJkLFxuICBzdXBwb3J0c1RhYlN0YXR1cyxcbiAgd3JhcEZvck11bHRpcGxleGVyLFxufSBmcm9tICcuL3Rlcm1pby9vc2MuanMnXG5pbXBvcnQgeyBUZXJtaW5hbFdyaXRlUHJvdmlkZXIgfSBmcm9tICcuL3VzZVRlcm1pbmFsTm90aWZpY2F0aW9uLmpzJ1xuXG4vLyBBbHQtc2NyZWVuOiByZW5kZXJlci50cyBzZXRzIGN1cnNvci52aXNpYmxlID0gIWlzVFRZIHx8IHNjcmVlbi5oZWlnaHQ9PT0wLFxuLy8gd2hpY2ggaXMgYWx3YXlzIGZhbHNlIGluIGFsdC1zY3JlZW4gKFRUWSArIGNvbnRlbnQgZmlsbHMgc2NyZWVuKS5cbi8vIFJldXNpbmcgYSBmcm96ZW4gb2JqZWN0IHNhdmVzIDEgYWxsb2NhdGlvbiBwZXIgZnJhbWUuXG5jb25zdCBBTFRfU0NSRUVOX0FOQ0hPUl9DVVJTT1IgPSBPYmplY3QuZnJlZXplKHsgeDogMCwgeTogMCwgdmlzaWJsZTogZmFsc2UgfSlcbmNvbnN0IENVUlNPUl9IT01FX1BBVENIID0gT2JqZWN0LmZyZWV6ZSh7XG4gIHR5cGU6ICdzdGRvdXQnIGFzIGNvbnN0LFxuICBjb250ZW50OiBDVVJTT1JfSE9NRSxcbn0pXG5jb25zdCBFUkFTRV9USEVOX0hPTUVfUEFUQ0ggPSBPYmplY3QuZnJlZXplKHtcbiAgdHlwZTogJ3N0ZG91dCcgYXMgY29uc3QsXG4gIGNvbnRlbnQ6IEVSQVNFX1NDUkVFTiArIENVUlNPUl9IT01FLFxufSlcblxuLy8gQ2FjaGVkIHBlci1JbmstaW5zdGFuY2UsIGludmFsaWRhdGVkIG9uIHJlc2l6ZS4gZnJhbWUuY3Vyc29yLnkgZm9yXG4vLyBhbHQtc2NyZWVuIGlzIGFsd2F5cyB0ZXJtaW5hbFJvd3MgLSAxIChyZW5kZXJlci50cykuXG5mdW5jdGlvbiBtYWtlQWx0U2NyZWVuUGFya1BhdGNoKHRlcm1pbmFsUm93czogbnVtYmVyKSB7XG4gIHJldHVybiBPYmplY3QuZnJlZXplKHtcbiAgICB0eXBlOiAnc3Rkb3V0JyBhcyBjb25zdCxcbiAgICBjb250ZW50OiBjdXJzb3JQb3NpdGlvbih0ZXJtaW5hbFJvd3MsIDEpLFxuICB9KVxufVxuXG5leHBvcnQgdHlwZSBPcHRpb25zID0ge1xuICBzdGRvdXQ6IE5vZGVKUy5Xcml0ZVN0cmVhbVxuICBzdGRpbjogTm9kZUpTLlJlYWRTdHJlYW1cbiAgc3RkZXJyOiBOb2RlSlMuV3JpdGVTdHJlYW1cbiAgZXhpdE9uQ3RybEM6IGJvb2xlYW5cbiAgcGF0Y2hDb25zb2xlOiBib29sZWFuXG4gIHdhaXRVbnRpbEV4aXQ/OiAoKSA9PiBQcm9taXNlPHZvaWQ+XG4gIG9uRnJhbWU/OiAoZXZlbnQ6IEZyYW1lRXZlbnQpID0+IHZvaWRcbn1cblxuZXhwb3J0IGRlZmF1bHQgY2xhc3MgSW5rIHtcbiAgcHJpdmF0ZSByZWFkb25seSBsb2c6IExvZ1VwZGF0ZVxuICBwcml2YXRlIHJlYWRvbmx5IHRlcm1pbmFsOiBUZXJtaW5hbFxuICBwcml2YXRlIHNjaGVkdWxlUmVuZGVyOiAoKCkgPT4gdm9pZCkgJiB7IGNhbmNlbD86ICgpID0+IHZvaWQgfVxuICAvLyBJZ25vcmUgbGFzdCByZW5kZXIgYWZ0ZXIgdW5tb3VudGluZyBhIHRyZWUgdG8gcHJldmVudCBlbXB0eSBvdXRwdXQgYmVmb3JlIGV4aXRcbiAgcHJpdmF0ZSBpc1VubW91bnRlZCA9IGZhbHNlXG4gIHByaXZhdGUgaXNQYXVzZWQgPSBmYWxzZVxuICBwcml2YXRlIHJlYWRvbmx5IGNvbnRhaW5lcjogRmliZXJSb290XG4gIHByaXZhdGUgcm9vdE5vZGU6IGRvbS5ET01FbGVtZW50XG4gIHJlYWRvbmx5IGZvY3VzTWFuYWdlcjogRm9jdXNNYW5hZ2VyXG4gIHByaXZhdGUgcmVuZGVyZXI6IFJlbmRlcmVyXG4gIHByaXZhdGUgcmVhZG9ubHkgc3R5bGVQb29sOiBTdHlsZVBvb2xcbiAgcHJpdmF0ZSBjaGFyUG9vbDogQ2hhclBvb2xcbiAgcHJpdmF0ZSBoeXBlcmxpbmtQb29sOiBIeXBlcmxpbmtQb29sXG4gIHByaXZhdGUgZXhpdFByb21pc2U/OiBQcm9taXNlPHZvaWQ+XG4gIHByaXZhdGUgcmVzdG9yZUNvbnNvbGU/OiAoKSA9PiB2b2lkXG4gIHByaXZhdGUgcmVzdG9yZVN0ZGVycj86ICgpID0+IHZvaWRcbiAgcHJpdmF0ZSByZWFkb25seSB1bnN1YnNjcmliZVRUWUhhbmRsZXJzPzogKCkgPT4gdm9pZFxuICBwcml2YXRlIHRlcm1pbmFsQ29sdW1uczogbnVtYmVyXG4gIHByaXZhdGUgdGVybWluYWxSb3dzOiBudW1iZXJcbiAgcHJpdmF0ZSBjdXJyZW50Tm9kZTogUmVhY3ROb2RlID0gbnVsbFxuICBwcml2YXRlIGZyb250RnJhbWU6IEZyYW1lXG4gIHByaXZhdGUgYmFja0ZyYW1lOiBGcmFtZVxuICBwcml2YXRlIGxhc3RQb29sUmVzZXRUaW1lID0gcGVyZm9ybWFuY2Uubm93KClcbiAgcHJpdmF0ZSBkcmFpblRpbWVyOiBSZXR1cm5UeXBlPHR5cGVvZiBzZXRUaW1lb3V0PiB8IG51bGwgPSBudWxsXG4gIHByaXZhdGUgbGFzdFlvZ2FDb3VudGVyczoge1xuICAgIG1zOiBudW1iZXJcbiAgICB2aXNpdGVkOiBudW1iZXJcbiAgICBtZWFzdXJlZDogbnVtYmVyXG4gICAgY2FjaGVIaXRzOiBudW1iZXJcbiAgICBsaXZlOiBudW1iZXJcbiAgfSA9IHsgbXM6IDAsIHZpc2l0ZWQ6IDAsIG1lYXN1cmVkOiAwLCBjYWNoZUhpdHM6IDAsIGxpdmU6IDAgfVxuICBwcml2YXRlIGFsdFNjcmVlblBhcmtQYXRjaDogUmVhZG9ubHk8eyB0eXBlOiAnc3Rkb3V0JzsgY29udGVudDogc3RyaW5nIH0+XG4gIC8vIFRleHQgc2VsZWN0aW9uIHN0YXRlIChhbHQtc2NyZWVuIG9ubHkpLiBPd25lZCBoZXJlIHNvIHRoZSBvdmVybGF5XG4gIC8vIHBhc3MgaW4gb25SZW5kZXIgY2FuIHJlYWQgaXQgYW5kIEFwcC50c3ggY2FuIHVwZGF0ZSBpdCBmcm9tIG1vdXNlXG4gIC8vIGV2ZW50cy4gUHVibGljIHNvIGluc3RhbmNlcy5nZXQoKSBjYWxsZXJzIGNhbiBhY2Nlc3MuXG4gIHJlYWRvbmx5IHNlbGVjdGlvbjogU2VsZWN0aW9uU3RhdGUgPSBjcmVhdGVTZWxlY3Rpb25TdGF0ZSgpXG4gIC8vIFNlYXJjaCBoaWdobGlnaHQgcXVlcnkgKGFsdC1zY3JlZW4gb25seSkuIFNldHRlciBiZWxvdyB0cmlnZ2Vyc1xuICAvLyBzY2hlZHVsZVJlbmRlcjsgYXBwbHlTZWFyY2hIaWdobGlnaHQgaW4gb25SZW5kZXIgaW52ZXJ0cyBtYXRjaGluZyBjZWxscy5cbiAgcHJpdmF0ZSBzZWFyY2hIaWdobGlnaHRRdWVyeSA9ICcnXG4gIC8vIFBvc2l0aW9uLWJhc2VkIGhpZ2hsaWdodC4gVk1MIHNjYW5zIHBvc2l0aW9ucyBPTkNFICh2aWFcbiAgLy8gc2NhbkVsZW1lbnRTdWJ0cmVlLCB3aGVuIHRoZSB0YXJnZXQgbWVzc2FnZSBpcyBtb3VudGVkKSwgc3RvcmVzIHRoZW1cbiAgLy8gbWVzc2FnZS1yZWxhdGl2ZSwgc2V0cyB0aGlzIGZvciBldmVyeS1mcmFtZSBhcHBseS4gcm93T2Zmc2V0ID1cbiAgLy8gbWVzc2FnZSdzIGN1cnJlbnQgc2NyZWVuLXRvcC4gY3VycmVudElkeCA9IHdoaWNoIHBvc2l0aW9uIGlzXG4gIC8vIFwiY3VycmVudFwiICh5ZWxsb3cpLiBudWxsIGNsZWFycy4gUG9zaXRpb25zIGFyZSBrbm93biB1cGZyb250IOKAlFxuICAvLyBuYXZpZ2F0aW9uIGlzIGluZGV4IGFyaXRobWV0aWMsIG5vIHNjYW4tZmVlZGJhY2sgbG9vcC5cbiAgcHJpdmF0ZSBzZWFyY2hQb3NpdGlvbnM6IHtcbiAgICBwb3NpdGlvbnM6IE1hdGNoUG9zaXRpb25bXVxuICAgIHJvd09mZnNldDogbnVtYmVyXG4gICAgY3VycmVudElkeDogbnVtYmVyXG4gIH0gfCBudWxsID0gbnVsbFxuICAvLyBSZWFjdC1sYW5kIHN1YnNjcmliZXJzIGZvciBzZWxlY3Rpb24gc3RhdGUgY2hhbmdlcyAodXNlSGFzU2VsZWN0aW9uKS5cbiAgLy8gRmlyZWQgYWxvbmdzaWRlIHRoZSB0ZXJtaW5hbCByZXBhaW50IHdoZW5ldmVyIHRoZSBzZWxlY3Rpb24gbXV0YXRlc1xuICAvLyBzbyBVSSAoZS5nLiBmb290ZXIgaGludHMpIGNhbiByZWFjdCB0byBzZWxlY3Rpb24gYXBwZWFyaW5nL2NsZWFyaW5nLlxuICBwcml2YXRlIHJlYWRvbmx5IHNlbGVjdGlvbkxpc3RlbmVycyA9IG5ldyBTZXQ8KCkgPT4gdm9pZD4oKVxuICAvLyBET00gbm9kZXMgY3VycmVudGx5IHVuZGVyIHRoZSBwb2ludGVyIChtb2RlLTEwMDMgbW90aW9uKS4gSGVsZCBoZXJlXG4gIC8vIHNvIEFwcC50c3gncyBoYW5kbGVNb3VzZUV2ZW50IGlzIHN0YXRlbGVzcyDigJQgZGlzcGF0Y2hIb3ZlciBkaWZmc1xuICAvLyBhZ2FpbnN0IHRoaXMgc2V0IGFuZCBtdXRhdGVzIGl0IGluIHBsYWNlLlxuICBwcml2YXRlIHJlYWRvbmx5IGhvdmVyZWROb2RlcyA9IG5ldyBTZXQ8ZG9tLkRPTUVsZW1lbnQ+KClcbiAgLy8gU2V0IGJ5IDxBbHRlcm5hdGVTY3JlZW4+IHZpYSBzZXRBbHRTY3JlZW5BY3RpdmUoKS4gQ29udHJvbHMgdGhlXG4gIC8vIHJlbmRlcmVyJ3MgY3Vyc29yLnkgY2xhbXBpbmcgKGtlZXBzIGN1cnNvciBpbi12aWV3cG9ydCB0byBhdm9pZFxuICAvLyBMRi1pbmR1Y2VkIHNjcm9sbCB3aGVuIHNjcmVlbi5oZWlnaHQgPT09IHRlcm1pbmFsUm93cykgYW5kIGdhdGVzXG4gIC8vIGFsdC1zY3JlZW4tYXdhcmUgU0lHQ09OVC9yZXNpemUvdW5tb3VudCBoYW5kbGluZy5cbiAgcHJpdmF0ZSBhbHRTY3JlZW5BY3RpdmUgPSBmYWxzZVxuICAvLyBTZXQgYWxvbmdzaWRlIGFsdFNjcmVlbkFjdGl2ZSBzbyBTSUdDT05UIHJlc3VtZSBrbm93cyB3aGV0aGVyIHRvXG4gIC8vIHJlLWVuYWJsZSBtb3VzZSB0cmFja2luZyAobm90IGFsbCA8QWx0ZXJuYXRlU2NyZWVuPiB1c2VzIHdhbnQgaXQpLlxuICBwcml2YXRlIGFsdFNjcmVlbk1vdXNlVHJhY2tpbmcgPSBmYWxzZVxuICAvLyBUcnVlIHdoZW4gdGhlIHByZXZpb3VzIGZyYW1lJ3Mgc2NyZWVuIGJ1ZmZlciBjYW5ub3QgYmUgdHJ1c3RlZCBmb3JcbiAgLy8gYmxpdCDigJQgc2VsZWN0aW9uIG92ZXJsYXkgbXV0YXRlZCBpdCwgcmVzZXRGcmFtZXNGb3JBbHRTY3JlZW4oKVxuICAvLyByZXBsYWNlZCBpdCB3aXRoIGJsYW5rcywgb3IgZm9yY2VSZWRyYXcoKSByZXNldCBpdCB0byAww5cwLiBGb3JjZXNcbiAgLy8gb25lIGZ1bGwtcmVuZGVyIGZyYW1lOyBzdGVhZHktc3RhdGUgZnJhbWVzIGFmdGVyIGNsZWFyIGl0IGFuZCByZWdhaW5cbiAgLy8gdGhlIGJsaXQgKyBuYXJyb3ctZGFtYWdlIGZhc3QgcGF0aC5cbiAgcHJpdmF0ZSBwcmV2RnJhbWVDb250YW1pbmF0ZWQgPSBmYWxzZVxuICAvLyBTZXQgYnkgaGFuZGxlUmVzaXplOiBwcmVwZW5kIEVSQVNFX1NDUkVFTiB0byB0aGUgbmV4dCBvblJlbmRlcidzIHBhdGNoZXNcbiAgLy8gSU5TSURFIHRoZSBCU1UvRVNVIGJsb2NrIHNvIGNsZWFyK3BhaW50IGlzIGF0b21pYy4gV3JpdGluZyBFUkFTRV9TQ1JFRU5cbiAgLy8gc3luY2hyb25vdXNseSBpbiBoYW5kbGVSZXNpemUgd291bGQgbGVhdmUgdGhlIHNjcmVlbiBibGFuayBmb3IgdGhlIH44MG1zXG4gIC8vIHJlbmRlcigpIHRha2VzOyBkZWZlcnJpbmcgaW50byB0aGUgYXRvbWljIGJsb2NrIG1lYW5zIG9sZCBjb250ZW50IHN0YXlzXG4gIC8vIHZpc2libGUgdW50aWwgdGhlIG5ldyBmcmFtZSBpcyBmdWxseSByZWFkeS5cbiAgcHJpdmF0ZSBuZWVkc0VyYXNlQmVmb3JlUGFpbnQgPSBmYWxzZVxuICAvLyBOYXRpdmUgY3Vyc29yIHBvc2l0aW9uaW5nOiBhIGNvbXBvbmVudCAodmlhIHVzZURlY2xhcmVkQ3Vyc29yKSBkZWNsYXJlc1xuICAvLyB3aGVyZSB0aGUgdGVybWluYWwgY3Vyc29yIHNob3VsZCBiZSBwYXJrZWQgYWZ0ZXIgZWFjaCBmcmFtZS4gVGVybWluYWxcbiAgLy8gZW11bGF0b3JzIHJlbmRlciBJTUUgcHJlZWRpdCB0ZXh0IGF0IHRoZSBwaHlzaWNhbCBjdXJzb3IgcG9zaXRpb24sIGFuZFxuICAvLyBzY3JlZW4gcmVhZGVycyAvIHNjcmVlbiBtYWduaWZpZXJzIHRyYWNrIGl0IOKAlCBzbyBwYXJraW5nIGF0IHRoZSB0ZXh0XG4gIC8vIGlucHV0J3MgY2FyZXQgbWFrZXMgQ0pLIGlucHV0IGFwcGVhciBpbmxpbmUgYW5kIGxldHMgYTExeSB0b29scyBmb2xsb3cuXG4gIHByaXZhdGUgY3Vyc29yRGVjbGFyYXRpb246IEN1cnNvckRlY2xhcmF0aW9uIHwgbnVsbCA9IG51bGxcbiAgLy8gTWFpbi1zY3JlZW46IHBoeXNpY2FsIGN1cnNvciBwb3NpdGlvbiBhZnRlciB0aGUgZGVjbGFyZWQtY3Vyc29yIG1vdmUsXG4gIC8vIHRyYWNrZWQgc2VwYXJhdGVseSBmcm9tIGZyYW1lLmN1cnNvciAod2hpY2ggbXVzdCBzdGF5IGF0IGNvbnRlbnQtYm90dG9tXG4gIC8vIGZvciBsb2ctdXBkYXRlJ3MgcmVsYXRpdmUtbW92ZSBpbnZhcmlhbnRzKS4gQWx0LXNjcmVlbiBkb2Vzbid0IG5lZWRcbiAgLy8gdGhpcyDigJQgZXZlcnkgZnJhbWUgYmVnaW5zIHdpdGggQ1NJIEguIG51bGwgPSBubyBtb3ZlIGVtaXR0ZWQgbGFzdCBmcmFtZS5cbiAgcHJpdmF0ZSBkaXNwbGF5Q3Vyc29yOiB7IHg6IG51bWJlcjsgeTogbnVtYmVyIH0gfCBudWxsID0gbnVsbFxuXG4gIGNvbnN0cnVjdG9yKHByaXZhdGUgcmVhZG9ubHkgb3B0aW9uczogT3B0aW9ucykge1xuICAgIGF1dG9CaW5kKHRoaXMpXG5cbiAgICBpZiAodGhpcy5vcHRpb25zLnBhdGNoQ29uc29sZSkge1xuICAgICAgdGhpcy5yZXN0b3JlQ29uc29sZSA9IHRoaXMucGF0Y2hDb25zb2xlKClcbiAgICAgIHRoaXMucmVzdG9yZVN0ZGVyciA9IHRoaXMucGF0Y2hTdGRlcnIoKVxuICAgIH1cblxuICAgIHRoaXMudGVybWluYWwgPSB7XG4gICAgICBzdGRvdXQ6IG9wdGlvbnMuc3Rkb3V0LFxuICAgICAgc3RkZXJyOiBvcHRpb25zLnN0ZGVycixcbiAgICB9XG5cbiAgICB0aGlzLnRlcm1pbmFsQ29sdW1ucyA9IG9wdGlvbnMuc3Rkb3V0LmNvbHVtbnMgfHwgODBcbiAgICB0aGlzLnRlcm1pbmFsUm93cyA9IG9wdGlvbnMuc3Rkb3V0LnJvd3MgfHwgMjRcbiAgICB0aGlzLmFsdFNjcmVlblBhcmtQYXRjaCA9IG1ha2VBbHRTY3JlZW5QYXJrUGF0Y2godGhpcy50ZXJtaW5hbFJvd3MpXG4gICAgdGhpcy5zdHlsZVBvb2wgPSBuZXcgU3R5bGVQb29sKClcbiAgICB0aGlzLmNoYXJQb29sID0gbmV3IENoYXJQb29sKClcbiAgICB0aGlzLmh5cGVybGlua1Bvb2wgPSBuZXcgSHlwZXJsaW5rUG9vbCgpXG4gICAgdGhpcy5mcm9udEZyYW1lID0gZW1wdHlGcmFtZShcbiAgICAgIHRoaXMudGVybWluYWxSb3dzLFxuICAgICAgdGhpcy50ZXJtaW5hbENvbHVtbnMsXG4gICAgICB0aGlzLnN0eWxlUG9vbCxcbiAgICAgIHRoaXMuY2hhclBvb2wsXG4gICAgICB0aGlzLmh5cGVybGlua1Bvb2wsXG4gICAgKVxuICAgIHRoaXMuYmFja0ZyYW1lID0gZW1wdHlGcmFtZShcbiAgICAgIHRoaXMudGVybWluYWxSb3dzLFxuICAgICAgdGhpcy50ZXJtaW5hbENvbHVtbnMsXG4gICAgICB0aGlzLnN0eWxlUG9vbCxcbiAgICAgIHRoaXMuY2hhclBvb2wsXG4gICAgICB0aGlzLmh5cGVybGlua1Bvb2wsXG4gICAgKVxuXG4gICAgdGhpcy5sb2cgPSBuZXcgTG9nVXBkYXRlKHtcbiAgICAgIGlzVFRZOiAob3B0aW9ucy5zdGRvdXQuaXNUVFkgYXMgYm9vbGVhbiB8IHVuZGVmaW5lZCkgfHwgZmFsc2UsXG4gICAgICBzdHlsZVBvb2w6IHRoaXMuc3R5bGVQb29sLFxuICAgIH0pXG5cbiAgICAvLyBzY2hlZHVsZVJlbmRlciBpcyBjYWxsZWQgZnJvbSB0aGUgcmVjb25jaWxlcidzIHJlc2V0QWZ0ZXJDb21taXQsIHdoaWNoXG4gICAgLy8gcnVucyBCRUZPUkUgUmVhY3QncyBsYXlvdXQgcGhhc2UgKHJlZiBhdHRhY2ggKyB1c2VMYXlvdXRFZmZlY3QpLiBBbnlcbiAgICAvLyBzdGF0ZSBzZXQgaW4gbGF5b3V0IGVmZmVjdHMg4oCUIG5vdGFibHkgdGhlIGN1cnNvckRlY2xhcmF0aW9uIGZyb21cbiAgICAvLyB1c2VEZWNsYXJlZEN1cnNvciDigJQgd291bGQgbGFnIG9uZSBjb21taXQgYmVoaW5kIGlmIHdlIHJlbmRlcmVkXG4gICAgLy8gc3luY2hyb25vdXNseS4gRGVmZXJyaW5nIHRvIGEgbWljcm90YXNrIHJ1bnMgb25SZW5kZXIgYWZ0ZXIgbGF5b3V0XG4gICAgLy8gZWZmZWN0cyBoYXZlIGNvbW1pdHRlZCwgc28gdGhlIG5hdGl2ZSBjdXJzb3IgdHJhY2tzIHRoZSBjYXJldCB3aXRob3V0XG4gICAgLy8gYSBvbmUta2V5c3Ryb2tlIGxhZy4gU2FtZSBldmVudC1sb29wIHRpY2ssIHNvIHRocm91Z2hwdXQgaXMgdW5jaGFuZ2VkLlxuICAgIC8vIFRlc3QgZW52IHVzZXMgb25JbW1lZGlhdGVSZW5kZXIgKGRpcmVjdCBvblJlbmRlciwgbm8gdGhyb3R0bGUpIHNvXG4gICAgLy8gZXhpc3Rpbmcgc3luY2hyb25vdXMgbGFzdEZyYW1lKCkgdGVzdHMgYXJlIHVuYWZmZWN0ZWQuXG4gICAgY29uc3QgZGVmZXJyZWRSZW5kZXIgPSAoKTogdm9pZCA9PiBxdWV1ZU1pY3JvdGFzayh0aGlzLm9uUmVuZGVyKVxuICAgIHRoaXMuc2NoZWR1bGVSZW5kZXIgPSB0aHJvdHRsZShkZWZlcnJlZFJlbmRlciwgRlJBTUVfSU5URVJWQUxfTVMsIHtcbiAgICAgIGxlYWRpbmc6IHRydWUsXG4gICAgICB0cmFpbGluZzogdHJ1ZSxcbiAgICB9KVxuXG4gICAgLy8gSWdub3JlIGxhc3QgcmVuZGVyIGFmdGVyIHVubW91bnRpbmcgYSB0cmVlIHRvIHByZXZlbnQgZW1wdHkgb3V0cHV0IGJlZm9yZSBleGl0XG4gICAgdGhpcy5pc1VubW91bnRlZCA9IGZhbHNlXG5cbiAgICAvLyBVbm1vdW50IHdoZW4gcHJvY2VzcyBleGl0c1xuICAgIHRoaXMudW5zdWJzY3JpYmVFeGl0ID0gb25FeGl0KHRoaXMudW5tb3VudCwgeyBhbHdheXNMYXN0OiBmYWxzZSB9KVxuXG4gICAgaWYgKG9wdGlvbnMuc3Rkb3V0LmlzVFRZKSB7XG4gICAgICBvcHRpb25zLnN0ZG91dC5vbigncmVzaXplJywgdGhpcy5oYW5kbGVSZXNpemUpXG4gICAgICBwcm9jZXNzLm9uKCdTSUdDT05UJywgdGhpcy5oYW5kbGVSZXN1bWUpXG5cbiAgICAgIHRoaXMudW5zdWJzY3JpYmVUVFlIYW5kbGVycyA9ICgpID0+IHtcbiAgICAgICAgb3B0aW9ucy5zdGRvdXQub2ZmKCdyZXNpemUnLCB0aGlzLmhhbmRsZVJlc2l6ZSlcbiAgICAgICAgcHJvY2Vzcy5vZmYoJ1NJR0NPTlQnLCB0aGlzLmhhbmRsZVJlc3VtZSlcbiAgICAgIH1cbiAgICB9XG5cbiAgICB0aGlzLnJvb3ROb2RlID0gZG9tLmNyZWF0ZU5vZGUoJ2luay1yb290JylcbiAgICB0aGlzLmZvY3VzTWFuYWdlciA9IG5ldyBGb2N1c01hbmFnZXIoKHRhcmdldCwgZXZlbnQpID0+XG4gICAgICBkaXNwYXRjaGVyLmRpc3BhdGNoRGlzY3JldGUodGFyZ2V0LCBldmVudCksXG4gICAgKVxuICAgIHRoaXMucm9vdE5vZGUuZm9jdXNNYW5hZ2VyID0gdGhpcy5mb2N1c01hbmFnZXJcbiAgICB0aGlzLnJlbmRlcmVyID0gY3JlYXRlUmVuZGVyZXIodGhpcy5yb290Tm9kZSwgdGhpcy5zdHlsZVBvb2wpXG4gICAgdGhpcy5yb290Tm9kZS5vblJlbmRlciA9IHRoaXMuc2NoZWR1bGVSZW5kZXJcbiAgICB0aGlzLnJvb3ROb2RlLm9uSW1tZWRpYXRlUmVuZGVyID0gdGhpcy5vblJlbmRlclxuICAgIHRoaXMucm9vdE5vZGUub25Db21wdXRlTGF5b3V0ID0gKCkgPT4ge1xuICAgICAgLy8gQ2FsY3VsYXRlIGxheW91dCBkdXJpbmcgUmVhY3QncyBjb21taXQgcGhhc2Ugc28gdXNlTGF5b3V0RWZmZWN0IGhvb2tzXG4gICAgICAvLyBoYXZlIGFjY2VzcyB0byBmcmVzaCBsYXlvdXQgZGF0YVxuICAgICAgLy8gR3VhcmQgYWdhaW5zdCBhY2Nlc3NpbmcgZnJlZWQgWW9nYSBub2RlcyBhZnRlciB1bm1vdW50XG4gICAgICBpZiAodGhpcy5pc1VubW91bnRlZCkge1xuICAgICAgICByZXR1cm5cbiAgICAgIH1cblxuICAgICAgaWYgKHRoaXMucm9vdE5vZGUueW9nYU5vZGUpIHtcbiAgICAgICAgY29uc3QgdDAgPSBwZXJmb3JtYW5jZS5ub3coKVxuICAgICAgICB0aGlzLnJvb3ROb2RlLnlvZ2FOb2RlLnNldFdpZHRoKHRoaXMudGVybWluYWxDb2x1bW5zKVxuICAgICAgICB0aGlzLnJvb3ROb2RlLnlvZ2FOb2RlLmNhbGN1bGF0ZUxheW91dCh0aGlzLnRlcm1pbmFsQ29sdW1ucylcbiAgICAgICAgY29uc3QgbXMgPSBwZXJmb3JtYW5jZS5ub3coKSAtIHQwXG4gICAgICAgIHJlY29yZFlvZ2FNcyhtcylcbiAgICAgICAgY29uc3QgYyA9IGdldFlvZ2FDb3VudGVycygpXG4gICAgICAgIHRoaXMubGFzdFlvZ2FDb3VudGVycyA9IHsgbXMsIC4uLmMgfVxuICAgICAgfVxuICAgIH1cblxuICAgIC8vIEB0cy1leHBlY3QtZXJyb3IgQHR5cGVzL3JlYWN0LXJlY29uY2lsZXJAMC4zMi4zIGRlY2xhcmVzIDExIGFyZ3Mgd2l0aCB0cmFuc2l0aW9uQ2FsbGJhY2tzLFxuICAgIC8vIGJ1dCByZWFjdC1yZWNvbmNpbGVyIDAuMzMuMCBzb3VyY2Ugb25seSBhY2NlcHRzIDEwIGFyZ3MgKG5vIHRyYW5zaXRpb25DYWxsYmFja3MpXG4gICAgdGhpcy5jb250YWluZXIgPSByZWNvbmNpbGVyLmNyZWF0ZUNvbnRhaW5lcihcbiAgICAgIHRoaXMucm9vdE5vZGUsXG4gICAgICBDb25jdXJyZW50Um9vdCxcbiAgICAgIG51bGwsXG4gICAgICBmYWxzZSxcbiAgICAgIG51bGwsXG4gICAgICAnaWQnLFxuICAgICAgbm9vcCwgLy8gb25VbmNhdWdodEVycm9yXG4gICAgICBub29wLCAvLyBvbkNhdWdodEVycm9yXG4gICAgICBub29wLCAvLyBvblJlY292ZXJhYmxlRXJyb3JcbiAgICAgIG5vb3AsIC8vIG9uRGVmYXVsdFRyYW5zaXRpb25JbmRpY2F0b3JcbiAgICApXG5cbiAgICBpZiAoXCJwcm9kdWN0aW9uXCIgPT09ICdkZXZlbG9wbWVudCcpIHtcbiAgICAgIHJlY29uY2lsZXIuaW5qZWN0SW50b0RldlRvb2xzKHtcbiAgICAgICAgYnVuZGxlVHlwZTogMCxcbiAgICAgICAgLy8gUmVwb3J0aW5nIFJlYWN0IERPTSdzIHZlcnNpb24sIG5vdCBJbmsnc1xuICAgICAgICAvLyBTZWUgaHR0cHM6Ly9naXRodWIuY29tL2ZhY2Vib29rL3JlYWN0L2lzc3Vlcy8xNjY2NiNpc3N1ZWNvbW1lbnQtNTMyNjM5OTA1XG4gICAgICAgIHZlcnNpb246ICcxNi4xMy4xJyxcbiAgICAgICAgcmVuZGVyZXJQYWNrYWdlTmFtZTogJ2luaycsXG4gICAgICB9KVxuICAgIH1cbiAgfVxuXG4gIHByaXZhdGUgaGFuZGxlUmVzdW1lID0gKCkgPT4ge1xuICAgIGlmICghdGhpcy5vcHRpb25zLnN0ZG91dC5pc1RUWSkge1xuICAgICAgcmV0dXJuXG4gICAgfVxuXG4gICAgLy8gQWx0IHNjcmVlbjogYWZ0ZXIgU0lHQ09OVCwgY29udGVudCBpcyBzdGFsZSAoc2hlbGwgbWF5IGhhdmUgd3JpdHRlblxuICAgIC8vIHRvIG1haW4gc2NyZWVuLCBzd2l0Y2hpbmcgZm9jdXMgYXdheSkgYW5kIG1vdXNlIHRyYWNraW5nIHdhc1xuICAgIC8vIGRpc2FibGVkIGJ5IGhhbmRsZVN1c3BlbmQuXG4gICAgaWYgKHRoaXMuYWx0U2NyZWVuQWN0aXZlKSB7XG4gICAgICB0aGlzLnJlZW50ZXJBbHRTY3JlZW4oKVxuICAgICAgcmV0dXJuXG4gICAgfVxuXG4gICAgLy8gTWFpbiBzY3JlZW46IHN0YXJ0IGZyZXNoIHRvIHByZXZlbnQgY2xvYmJlcmluZyB0ZXJtaW5hbCBjb250ZW50XG4gICAgdGhpcy5mcm9udEZyYW1lID0gZW1wdHlGcmFtZShcbiAgICAgIHRoaXMuZnJvbnRGcmFtZS52aWV3cG9ydC5oZWlnaHQsXG4gICAgICB0aGlzLmZyb250RnJhbWUudmlld3BvcnQud2lkdGgsXG4gICAgICB0aGlzLnN0eWxlUG9vbCxcbiAgICAgIHRoaXMuY2hhclBvb2wsXG4gICAgICB0aGlzLmh5cGVybGlua1Bvb2wsXG4gICAgKVxuICAgIHRoaXMuYmFja0ZyYW1lID0gZW1wdHlGcmFtZShcbiAgICAgIHRoaXMuYmFja0ZyYW1lLnZpZXdwb3J0LmhlaWdodCxcbiAgICAgIHRoaXMuYmFja0ZyYW1lLnZpZXdwb3J0LndpZHRoLFxuICAgICAgdGhpcy5zdHlsZVBvb2wsXG4gICAgICB0aGlzLmNoYXJQb29sLFxuICAgICAgdGhpcy5oeXBlcmxpbmtQb29sLFxuICAgIClcbiAgICB0aGlzLmxvZy5yZXNldCgpXG4gICAgLy8gUGh5c2ljYWwgY3Vyc29yIHBvc2l0aW9uIGlzIHVua25vd24gYWZ0ZXIgdGhlIHNoZWxsIHRvb2sgb3ZlciBkdXJpbmdcbiAgICAvLyBzdXNwZW5kLiBDbGVhciBkaXNwbGF5Q3Vyc29yIHNvIHRoZSBuZXh0IGZyYW1lJ3MgY3Vyc29yIHByZWFtYmxlXG4gICAgLy8gZG9lc24ndCBlbWl0IGEgcmVsYXRpdmUgbW92ZSBmcm9tIGEgc3RhbGUgcGFyayBwb3NpdGlvbi5cbiAgICB0aGlzLmRpc3BsYXlDdXJzb3IgPSBudWxsXG4gIH1cblxuICAvLyBOT1QgZGVib3VuY2VkLiBBIGRlYm91bmNlIG9wZW5zIGEgd2luZG93IHdoZXJlIHN0ZG91dC5jb2x1bW5zIGlzIE5FV1xuICAvLyBidXQgdGhpcy50ZXJtaW5hbENvbHVtbnMvWW9nYSBhcmUgT0xEIOKAlCBhbnkgc2NoZWR1bGVSZW5kZXIgZHVyaW5nIHRoYXRcbiAgLy8gd2luZG93IChzcGlubmVyLCBjbG9jaykgbWFrZXMgbG9nLXVwZGF0ZSBkZXRlY3QgYSB3aWR0aCBjaGFuZ2UgYW5kXG4gIC8vIGNsZWFyIHRoZSBzY3JlZW4sIHRoZW4gdGhlIGRlYm91bmNlIGZpcmVzIGFuZCBjbGVhcnMgYWdhaW4gKGRvdWJsZVxuICAvLyBibGFua+KGknBhaW50IGZsaWNrZXIpLiB1c2VWaXJ0dWFsU2Nyb2xsJ3MgaGVpZ2h0IHNjYWxpbmcgYWxyZWFkeSBib3VuZHNcbiAgLy8gdGhlIHBlci1yZXNpemUgY29zdDsgc3luY2hyb25vdXMgaGFuZGxpbmcga2VlcHMgZGltZW5zaW9ucyBjb25zaXN0ZW50LlxuICBwcml2YXRlIGhhbmRsZVJlc2l6ZSA9ICgpID0+IHtcbiAgICBjb25zdCBjb2xzID0gdGhpcy5vcHRpb25zLnN0ZG91dC5jb2x1bW5zIHx8IDgwXG4gICAgY29uc3Qgcm93cyA9IHRoaXMub3B0aW9ucy5zdGRvdXQucm93cyB8fCAyNFxuICAgIC8vIFRlcm1pbmFscyBvZnRlbiBlbWl0IDIrIHJlc2l6ZSBldmVudHMgZm9yIG9uZSB1c2VyIGFjdGlvbiAod2luZG93XG4gICAgLy8gc2V0dGxpbmcpLiBTYW1lLWRpbWVuc2lvbiBldmVudHMgYXJlIG5vLW9wczsgc2tpcCB0byBhdm9pZCByZWR1bmRhbnRcbiAgICAvLyBmcmFtZSByZXNldHMgYW5kIHJlbmRlcnMuXG4gICAgaWYgKGNvbHMgPT09IHRoaXMudGVybWluYWxDb2x1bW5zICYmIHJvd3MgPT09IHRoaXMudGVybWluYWxSb3dzKSByZXR1cm5cbiAgICB0aGlzLnRlcm1pbmFsQ29sdW1ucyA9IGNvbHNcbiAgICB0aGlzLnRlcm1pbmFsUm93cyA9IHJvd3NcbiAgICB0aGlzLmFsdFNjcmVlblBhcmtQYXRjaCA9IG1ha2VBbHRTY3JlZW5QYXJrUGF0Y2godGhpcy50ZXJtaW5hbFJvd3MpXG5cbiAgICAvLyBBbHQgc2NyZWVuOiByZXNldCBmcmFtZSBidWZmZXJzIHNvIHRoZSBuZXh0IHJlbmRlciByZXBhaW50cyBmcm9tXG4gICAgLy8gc2NyYXRjaCAocHJldkZyYW1lQ29udGFtaW5hdGVkIOKGkiBldmVyeSBjZWxsIHdyaXR0ZW4sIHdyYXBwZWQgaW5cbiAgICAvLyBCU1UvRVNVIOKAlCBvbGQgY29udGVudCBzdGF5cyB2aXNpYmxlIHVudGlsIHRoZSBuZXcgZnJhbWUgc3dhcHNcbiAgICAvLyBhdG9taWNhbGx5KS4gUmUtYXNzZXJ0IG1vdXNlIHRyYWNraW5nIChzb21lIGVtdWxhdG9ycyByZXNldCBpdCBvblxuICAgIC8vIHJlc2l6ZSkuIERvIE5PVCB3cml0ZSBFTlRFUl9BTFRfU0NSRUVOOiBpVGVybTIgdHJlYXRzID8xMDQ5aCBhcyBhXG4gICAgLy8gYnVmZmVyIGNsZWFyIGV2ZW4gd2hlbiBhbHJlYWR5IGluIGFsdCDigJQgdGhhdCdzIHRoZSBibGFuayBmbGlja2VyLlxuICAgIC8vIFNlbGYtaGVhbGluZyByZS1lbnRyeSAoaWYgc29tZXRoaW5nIGtpY2tlZCB1cyBvdXQgb2YgYWx0KSBpcyBoYW5kbGVkXG4gICAgLy8gYnkgaGFuZGxlUmVzdW1lIChTSUdDT05UKSBhbmQgdGhlIHNsZWVwLXdha2UgZGV0ZWN0b3I7IHJlc2l6ZSBpdHNlbGZcbiAgICAvLyBkb2Vzbid0IGV4aXQgYWx0LXNjcmVlbi4gRG8gTk9UIHdyaXRlIEVSQVNFX1NDUkVFTjogcmVuZGVyKCkgYmVsb3dcbiAgICAvLyBjYW4gdGFrZSB+ODBtczsgZXJhc2luZyBmaXJzdCBsZWF2ZXMgdGhlIHNjcmVlbiBibGFuayB0aGF0IHdob2xlIHRpbWUuXG4gICAgaWYgKHRoaXMuYWx0U2NyZWVuQWN0aXZlICYmICF0aGlzLmlzUGF1c2VkICYmIHRoaXMub3B0aW9ucy5zdGRvdXQuaXNUVFkpIHtcbiAgICAgIGlmICh0aGlzLmFsdFNjcmVlbk1vdXNlVHJhY2tpbmcpIHtcbiAgICAgICAgdGhpcy5vcHRpb25zLnN0ZG91dC53cml0ZShFTkFCTEVfTU9VU0VfVFJBQ0tJTkcpXG4gICAgICB9XG4gICAgICB0aGlzLnJlc2V0RnJhbWVzRm9yQWx0U2NyZWVuKClcbiAgICAgIHRoaXMubmVlZHNFcmFzZUJlZm9yZVBhaW50ID0gdHJ1ZVxuICAgIH1cblxuICAgIC8vIFJlLXJlbmRlciB0aGUgUmVhY3QgdHJlZSB3aXRoIHVwZGF0ZWQgcHJvcHMgc28gdGhlIGNvbnRleHQgdmFsdWUgY2hhbmdlcy5cbiAgICAvLyBSZWFjdCdzIGNvbW1pdCBwaGFzZSB3aWxsIGNhbGwgb25Db21wdXRlTGF5b3V0KCkgdG8gcmVjYWxjdWxhdGUgeW9nYSBsYXlvdXRcbiAgICAvLyB3aXRoIHRoZSBuZXcgZGltZW5zaW9ucywgdGhlbiBjYWxsIG9uUmVuZGVyKCkgdG8gcmVuZGVyIHRoZSB1cGRhdGVkIGZyYW1lLlxuICAgIC8vIFdlIGRvbid0IGNhbGwgc2NoZWR1bGVSZW5kZXIoKSBoZXJlIGJlY2F1c2UgdGhhdCB3b3VsZCByZW5kZXIgYmVmb3JlIHRoZVxuICAgIC8vIGxheW91dCBpcyB1cGRhdGVkLCBjYXVzaW5nIGEgbWlzbWF0Y2ggYmV0d2VlbiB2aWV3cG9ydCBhbmQgY29udGVudCBkaW1lbnNpb25zLlxuICAgIGlmICh0aGlzLmN1cnJlbnROb2RlICE9PSBudWxsKSB7XG4gICAgICB0aGlzLnJlbmRlcih0aGlzLmN1cnJlbnROb2RlKVxuICAgIH1cbiAgfVxuXG4gIHJlc29sdmVFeGl0UHJvbWlzZTogKCkgPT4gdm9pZCA9ICgpID0+IHt9XG4gIHJlamVjdEV4aXRQcm9taXNlOiAocmVhc29uPzogRXJyb3IpID0+IHZvaWQgPSAoKSA9PiB7fVxuICB1bnN1YnNjcmliZUV4aXQ6ICgpID0+IHZvaWQgPSAoKSA9PiB7fVxuXG4gIC8qKlxuICAgKiBQYXVzZSBJbmsgYW5kIGhhbmQgdGhlIHRlcm1pbmFsIG92ZXIgdG8gYW4gZXh0ZXJuYWwgVFVJIChlLmcuIGdpdFxuICAgKiBjb21taXQgZWRpdG9yKS4gSW4gbm9uLWZ1bGxzY3JlZW4gbW9kZSB0aGlzIGVudGVycyB0aGUgYWx0IHNjcmVlbjtcbiAgICogaW4gZnVsbHNjcmVlbiBtb2RlIHdlJ3JlIGFscmVhZHkgaW4gYWx0IHNvIHdlIGp1c3QgY2xlYXIgaXQuXG4gICAqIENhbGwgYGV4aXRBbHRlcm5hdGVTY3JlZW4oKWAgd2hlbiBkb25lIHRvIHJlc3RvcmUgSW5rLlxuICAgKi9cbiAgZW50ZXJBbHRlcm5hdGVTY3JlZW4oKTogdm9pZCB7XG4gICAgdGhpcy5wYXVzZSgpXG4gICAgdGhpcy5zdXNwZW5kU3RkaW4oKVxuICAgIHRoaXMub3B0aW9ucy5zdGRvdXQud3JpdGUoXG4gICAgICAvLyBEaXNhYmxlIGV4dGVuZGVkIGtleSByZXBvcnRpbmcgZmlyc3Qg4oCUIGVkaXRvcnMgdGhhdCBkb24ndCBzcGVha1xuICAgICAgLy8gQ1NJLXUgKGUuZy4gbmFubykgc2hvdyBcIlVua25vd24gc2VxdWVuY2VcIiBmb3IgZXZlcnkgQ3RybC08a2V5PiBpZlxuICAgICAgLy8ga2l0dHkvbW9kaWZ5T3RoZXJLZXlzIHN0YXlzIGFjdGl2ZS4gZXhpdEFsdGVybmF0ZVNjcmVlbiByZS1lbmFibGVzLlxuICAgICAgRElTQUJMRV9LSVRUWV9LRVlCT0FSRCArXG4gICAgICAgIERJU0FCTEVfTU9ESUZZX09USEVSX0tFWVMgK1xuICAgICAgICAodGhpcy5hbHRTY3JlZW5Nb3VzZVRyYWNraW5nID8gRElTQUJMRV9NT1VTRV9UUkFDS0lORyA6ICcnKSArIC8vIGRpc2FibGUgbW91c2UgKG5vLW9wIGlmIG9mZilcbiAgICAgICAgKHRoaXMuYWx0U2NyZWVuQWN0aXZlID8gJycgOiAnXFx4MWJbPzEwNDloJykgKyAvLyBlbnRlciBhbHQgKGFscmVhZHkgaW4gYWx0IGlmIGZ1bGxzY3JlZW4pXG4gICAgICAgICdcXHgxYls/MTAwNGwnICsgLy8gZGlzYWJsZSBmb2N1cyByZXBvcnRpbmdcbiAgICAgICAgJ1xceDFiWzBtJyArIC8vIHJlc2V0IGF0dHJpYnV0ZXNcbiAgICAgICAgJ1xceDFiWz8yNWgnICsgLy8gc2hvdyBjdXJzb3JcbiAgICAgICAgJ1xceDFiWzJKJyArIC8vIGNsZWFyIHNjcmVlblxuICAgICAgICAnXFx4MWJbSCcsIC8vIGN1cnNvciBob21lXG4gICAgKVxuICB9XG5cbiAgLyoqXG4gICAqIFJlc3VtZSBJbmsgYWZ0ZXIgYW4gZXh0ZXJuYWwgVFVJIGhhbmRvZmYgd2l0aCBhIGZ1bGwgcmVwYWludC5cbiAgICogSW4gbm9uLWZ1bGxzY3JlZW4gbW9kZSB0aGlzIGV4aXRzIHRoZSBhbHQgc2NyZWVuIGJhY2sgdG8gbWFpbjtcbiAgICogaW4gZnVsbHNjcmVlbiBtb2RlIHdlIHJlLWVudGVyIGFsdCBhbmQgY2xlYXIgKyByZXBhaW50LlxuICAgKlxuICAgKiBUaGUgcmUtZW50ZXIgbWF0dGVyczogdGVybWluYWwgZWRpdG9ycyAodmltLCBuYW5vLCBsZXNzKSB3cml0ZVxuICAgKiBzbWN1cC9ybWN1cCAoPzEwNDloLz8xMDQ5bCksIHNvIGV2ZW4gdGhvdWdoIHdlIHN0YXJ0ZWQgaW4gYWx0LFxuICAgKiB0aGUgZWRpdG9yJ3Mgcm1jdXAgb24gZXhpdCBkcm9wcyB1cyB0byBtYWluIHNjcmVlbi4gV2l0aG91dFxuICAgKiByZS1lbnRlcmluZywgdGhlIDJKIGJlbG93IHdpcGVzIHRoZSB1c2VyJ3MgbWFpbi1zY3JlZW4gc2Nyb2xsYmFja1xuICAgKiBhbmQgc3Vic2VxdWVudCByZW5kZXJzIGxhbmQgaW4gbWFpbiDigJQgbmF0aXZlIHRlcm1pbmFsIHNjcm9sbFxuICAgKiByZXR1cm5zLCBmdWxsc2NyZWVuIHNjcm9sbCBpcyBkZWFkLlxuICAgKi9cbiAgZXhpdEFsdGVybmF0ZVNjcmVlbigpOiB2b2lkIHtcbiAgICB0aGlzLm9wdGlvbnMuc3Rkb3V0LndyaXRlKFxuICAgICAgKHRoaXMuYWx0U2NyZWVuQWN0aXZlID8gRU5URVJfQUxUX1NDUkVFTiA6ICcnKSArIC8vIHJlLWVudGVyIGFsdCDigJQgdmltJ3Mgcm1jdXAgZHJvcHBlZCB1cyB0byBtYWluXG4gICAgICAgICdcXHgxYlsySicgKyAvLyBjbGVhciBzY3JlZW4gKG5vdyBhbHQgaWYgZnVsbHNjcmVlbilcbiAgICAgICAgJ1xceDFiW0gnICsgLy8gY3Vyc29yIGhvbWVcbiAgICAgICAgKHRoaXMuYWx0U2NyZWVuTW91c2VUcmFja2luZyA/IEVOQUJMRV9NT1VTRV9UUkFDS0lORyA6ICcnKSArIC8vIHJlLWVuYWJsZSBtb3VzZSAoc2tpcCBpZiBDTEFVREVfQ09ERV9ESVNBQkxFX01PVVNFKVxuICAgICAgICAodGhpcy5hbHRTY3JlZW5BY3RpdmUgPyAnJyA6ICdcXHgxYls/MTA0OWwnKSArIC8vIGV4aXQgYWx0IChub24tZnVsbHNjcmVlbiBvbmx5KVxuICAgICAgICAnXFx4MWJbPzI1bCcsIC8vIGhpZGUgY3Vyc29yIChJbmsgbWFuYWdlcylcbiAgICApXG4gICAgdGhpcy5yZXN1bWVTdGRpbigpXG4gICAgaWYgKHRoaXMuYWx0U2NyZWVuQWN0aXZlKSB7XG4gICAgICB0aGlzLnJlc2V0RnJhbWVzRm9yQWx0U2NyZWVuKClcbiAgICB9IGVsc2Uge1xuICAgICAgdGhpcy5yZXBhaW50KClcbiAgICB9XG4gICAgdGhpcy5yZXN1bWUoKVxuICAgIC8vIFJlLWVuYWJsZSBmb2N1cyByZXBvcnRpbmcgYW5kIGV4dGVuZGVkIGtleSByZXBvcnRpbmcg4oCUIHRlcm1pbmFsXG4gICAgLy8gZWRpdG9ycyAodmltLCBuYW5vLCBldGMuKSB3cml0ZSB0aGVpciBvd24gbW9kaWZ5T3RoZXJLZXlzIGxldmVsIG9uXG4gICAgLy8gZW50cnkgYW5kIHJlc2V0IGl0IG9uIGV4aXQsIGxlYXZpbmcgdXMgdW5hYmxlIHRvIGRpc3Rpbmd1aXNoXG4gICAgLy8gY3RybCtzaGlmdCs8bGV0dGVyPiBmcm9tIGN0cmwrPGxldHRlcj4uIFBvcC1iZWZvcmUtcHVzaCBrZWVwcyB0aGVcbiAgICAvLyBLaXR0eSBzdGFjayBiYWxhbmNlZCAoYSB3ZWxsLWJlaGF2ZWQgZWRpdG9yIHJlc3RvcmVzIG91ciBlbnRyeSwgc29cbiAgICAvLyB3aXRob3V0IHRoZSBwb3Agd2UnZCBhY2N1bXVsYXRlIGRlcHRoIG9uIGVhY2ggZWRpdG9yIHJvdW5kLXRyaXApLlxuICAgIHRoaXMub3B0aW9ucy5zdGRvdXQud3JpdGUoXG4gICAgICAnXFx4MWJbPzEwMDRoJyArXG4gICAgICAgIChzdXBwb3J0c0V4dGVuZGVkS2V5cygpXG4gICAgICAgICAgPyBESVNBQkxFX0tJVFRZX0tFWUJPQVJEICtcbiAgICAgICAgICAgIEVOQUJMRV9LSVRUWV9LRVlCT0FSRCArXG4gICAgICAgICAgICBFTkFCTEVfTU9ESUZZX09USEVSX0tFWVNcbiAgICAgICAgICA6ICcnKSxcbiAgICApXG4gIH1cblxuICBvblJlbmRlcigpIHtcbiAgICBpZiAodGhpcy5pc1VubW91bnRlZCB8fCB0aGlzLmlzUGF1c2VkKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG4gICAgLy8gRW50ZXJpbmcgYSByZW5kZXIgY2FuY2VscyBhbnkgcGVuZGluZyBkcmFpbiB0aWNrIOKAlCB0aGlzIHJlbmRlciB3aWxsXG4gICAgLy8gaGFuZGxlIHRoZSBkcmFpbiAoYW5kIHJlLXNjaGVkdWxlIGJlbG93IGlmIG5lZWRlZCkuIFByZXZlbnRzIGFcbiAgICAvLyB3aGVlbC1ldmVudC10cmlnZ2VyZWQgcmVuZGVyIEFORCBhIGRyYWluLXRpbWVyIHJlbmRlciBib3RoIGZpcmluZy5cbiAgICBpZiAodGhpcy5kcmFpblRpbWVyICE9PSBudWxsKSB7XG4gICAgICBjbGVhclRpbWVvdXQodGhpcy5kcmFpblRpbWVyKVxuICAgICAgdGhpcy5kcmFpblRpbWVyID0gbnVsbFxuICAgIH1cblxuICAgIC8vIEZsdXNoIGRlZmVycmVkIGludGVyYWN0aW9uLXRpbWUgdXBkYXRlIGJlZm9yZSByZW5kZXJpbmcgc28gd2UgY2FsbFxuICAgIC8vIERhdGUubm93KCkgYXQgbW9zdCBvbmNlIHBlciBmcmFtZSBpbnN0ZWFkIG9mIG9uY2UgcGVyIGtleXByZXNzLlxuICAgIC8vIERvbmUgYmVmb3JlIHRoZSByZW5kZXIgdG8gYXZvaWQgZGlydHlpbmcgc3RhdGUgdGhhdCB3b3VsZCB0cmlnZ2VyXG4gICAgLy8gYW4gZXh0cmEgUmVhY3QgcmUtcmVuZGVyIGN5Y2xlLlxuICAgIGZsdXNoSW50ZXJhY3Rpb25UaW1lKClcblxuICAgIGNvbnN0IHJlbmRlclN0YXJ0ID0gcGVyZm9ybWFuY2Uubm93KClcbiAgICBjb25zdCB0ZXJtaW5hbFdpZHRoID0gdGhpcy5vcHRpb25zLnN0ZG91dC5jb2x1bW5zIHx8IDgwXG4gICAgY29uc3QgdGVybWluYWxSb3dzID0gdGhpcy5vcHRpb25zLnN0ZG91dC5yb3dzIHx8IDI0XG5cbiAgICBjb25zdCBmcmFtZSA9IHRoaXMucmVuZGVyZXIoe1xuICAgICAgZnJvbnRGcmFtZTogdGhpcy5mcm9udEZyYW1lLFxuICAgICAgYmFja0ZyYW1lOiB0aGlzLmJhY2tGcmFtZSxcbiAgICAgIGlzVFRZOiB0aGlzLm9wdGlvbnMuc3Rkb3V0LmlzVFRZLFxuICAgICAgdGVybWluYWxXaWR0aCxcbiAgICAgIHRlcm1pbmFsUm93cyxcbiAgICAgIGFsdFNjcmVlbjogdGhpcy5hbHRTY3JlZW5BY3RpdmUsXG4gICAgICBwcmV2RnJhbWVDb250YW1pbmF0ZWQ6IHRoaXMucHJldkZyYW1lQ29udGFtaW5hdGVkLFxuICAgIH0pXG4gICAgY29uc3QgcmVuZGVyZXJNcyA9IHBlcmZvcm1hbmNlLm5vdygpIC0gcmVuZGVyU3RhcnRcblxuICAgIC8vIFN0aWNreS9hdXRvLWZvbGxvdyBzY3JvbGxlZCB0aGUgU2Nyb2xsQm94IHRoaXMgZnJhbWUuIFRyYW5zbGF0ZSB0aGVcbiAgICAvLyBzZWxlY3Rpb24gYnkgdGhlIHNhbWUgZGVsdGEgc28gdGhlIGhpZ2hsaWdodCBzdGF5cyBhbmNob3JlZCB0byB0aGVcbiAgICAvLyBURVhUIChuYXRpdmUgdGVybWluYWwgYmVoYXZpb3Ig4oCUIHRoZSBzZWxlY3Rpb24gd2Fsa3MgdXAgdGhlIHNjcmVlblxuICAgIC8vIGFzIGNvbnRlbnQgc2Nyb2xscywgZXZlbnR1YWxseSBjbGlwcGluZyBhdCB0aGUgdG9wKS4gZnJvbnRGcmFtZVxuICAgIC8vIHN0aWxsIGhvbGRzIHRoZSBQUkVWSU9VUyBmcmFtZSdzIHNjcmVlbiAoc3dhcCBpcyBhdCB+NTAwIGJlbG93KSwgc29cbiAgICAvLyBjYXB0dXJlU2Nyb2xsZWRSb3dzIHJlYWRzIHRoZSByb3dzIHRoYXQgYXJlIGFib3V0IHRvIHNjcm9sbCBvdXRcbiAgICAvLyBiZWZvcmUgdGhleSdyZSBvdmVyd3JpdHRlbiDigJQgdGhlIHRleHQgc3RheXMgY29weWFibGUgdW50aWwgdGhlXG4gICAgLy8gc2VsZWN0aW9uIHNjcm9sbHMgZW50aXJlbHkgb2ZmLiBEdXJpbmcgZHJhZywgZm9jdXMgdHJhY2tzIHRoZSBtb3VzZVxuICAgIC8vIChzY3JlZW4tbG9jYWwpIHNvIG9ubHkgYW5jaG9yIHNoaWZ0cyDigJQgc2VsZWN0aW9uIGdyb3dzIHRvd2FyZCB0aGVcbiAgICAvLyBtb3VzZSBhcyB0aGUgYW5jaG9yIHdhbGtzIHVwLiBBZnRlciByZWxlYXNlLCBib3RoIGVuZHMgYXJlIHRleHQtXG4gICAgLy8gYW5jaG9yZWQgYW5kIG1vdmUgYXMgYSBibG9jay5cbiAgICBjb25zdCBmb2xsb3cgPSBjb25zdW1lRm9sbG93U2Nyb2xsKClcbiAgICBpZiAoXG4gICAgICBmb2xsb3cgJiZcbiAgICAgIHRoaXMuc2VsZWN0aW9uLmFuY2hvciAmJlxuICAgICAgLy8gT25seSB0cmFuc2xhdGUgaWYgdGhlIHNlbGVjdGlvbiBpcyBPTiBzY3JvbGxib3ggY29udGVudC4gU2VsZWN0aW9uc1xuICAgICAgLy8gaW4gdGhlIGZvb3Rlci9wcm9tcHQvU3RpY2t5UHJvbXB0SGVhZGVyIGFyZSBvbiBzdGF0aWMgdGV4dCDigJQgdGhlXG4gICAgICAvLyBzY3JvbGwgZG9lc24ndCBtb3ZlIHdoYXQncyB1bmRlciB0aGVtLiBXaXRob3V0IHRoaXMgZ3VhcmQsIGFcbiAgICAgIC8vIGZvb3RlciBzZWxlY3Rpb24gd291bGQgYmUgc2hpZnRlZCBieSAtZGVsdGEgdGhlbiBjbGFtcGVkIHRvXG4gICAgICAvLyB2aWV3cG9ydEJvdHRvbSwgdGVsZXBvcnRpbmcgaXQgaW50byB0aGUgc2Nyb2xsYm94LiBNaXJyb3IgdGhlXG4gICAgICAvLyBib3VuZHMgY2hlY2sgdGhlIGRlbGV0ZWQgY2hlY2soKSBpbiBTY3JvbGxLZXliaW5kaW5nSGFuZGxlciBoYWQuXG4gICAgICB0aGlzLnNlbGVjdGlvbi5hbmNob3Iucm93ID49IGZvbGxvdy52aWV3cG9ydFRvcCAmJlxuICAgICAgdGhpcy5zZWxlY3Rpb24uYW5jaG9yLnJvdyA8PSBmb2xsb3cudmlld3BvcnRCb3R0b21cbiAgICApIHtcbiAgICAgIGNvbnN0IHsgZGVsdGEsIHZpZXdwb3J0VG9wLCB2aWV3cG9ydEJvdHRvbSB9ID0gZm9sbG93XG4gICAgICAvLyBjYXB0dXJlU2Nyb2xsZWRSb3dzIGFuZCBzaGlmdCogYXJlIGEgcGFpcjogY2FwdHVyZSBncmFicyByb3dzIGFib3V0XG4gICAgICAvLyB0byBzY3JvbGwgb2ZmLCBzaGlmdCBtb3ZlcyB0aGUgc2VsZWN0aW9uIGVuZHBvaW50IHNvIHRoZSBzYW1lIHJvd3NcbiAgICAgIC8vIHdvbid0IGludGVyc2VjdCBhZ2FpbiBuZXh0IGZyYW1lLiBDYXB0dXJpbmcgd2l0aG91dCBzaGlmdGluZyBsZWF2ZXNcbiAgICAgIC8vIHRoZSBlbmRwb2ludCBpbiBwbGFjZSwgc28gdGhlIFNBTUUgdmlld3BvcnQgcm93cyByZS1pbnRlcnNlY3QgZXZlcnlcbiAgICAgIC8vIGZyYW1lIGFuZCBzY3JvbGxlZE9mZkFib3ZlIGdyb3dzIHdpdGhvdXQgYm91bmQg4oCUIGdldFNlbGVjdGVkVGV4dFxuICAgICAgLy8gdGhlbiByZXR1cm5zIGV2ZXItZ3Jvd2luZyB0ZXh0IG9uIGVhY2ggcmUtY29weS4gS2VlcCBjYXB0dXJlIGluc2lkZVxuICAgICAgLy8gZWFjaCBzaGlmdCBicmFuY2ggc28gdGhlIHBhaXJpbmcgY2FuJ3QgYmUgYnJva2VuIGJ5IGEgbmV3IGd1YXJkLlxuICAgICAgaWYgKHRoaXMuc2VsZWN0aW9uLmlzRHJhZ2dpbmcpIHtcbiAgICAgICAgaWYgKGhhc1NlbGVjdGlvbih0aGlzLnNlbGVjdGlvbikpIHtcbiAgICAgICAgICBjYXB0dXJlU2Nyb2xsZWRSb3dzKFxuICAgICAgICAgICAgdGhpcy5zZWxlY3Rpb24sXG4gICAgICAgICAgICB0aGlzLmZyb250RnJhbWUuc2NyZWVuLFxuICAgICAgICAgICAgdmlld3BvcnRUb3AsXG4gICAgICAgICAgICB2aWV3cG9ydFRvcCArIGRlbHRhIC0gMSxcbiAgICAgICAgICAgICdhYm92ZScsXG4gICAgICAgICAgKVxuICAgICAgICB9XG4gICAgICAgIHNoaWZ0QW5jaG9yKHRoaXMuc2VsZWN0aW9uLCAtZGVsdGEsIHZpZXdwb3J0VG9wLCB2aWV3cG9ydEJvdHRvbSlcbiAgICAgIH0gZWxzZSBpZiAoXG4gICAgICAgIC8vIEZsYWctMyBndWFyZDogdGhlIGFuY2hvciBjaGVjayBhYm92ZSBvbmx5IHByb3ZlcyBPTkUgZW5kcG9pbnQgaXNcbiAgICAgICAgLy8gb24gc2Nyb2xsYm94IGNvbnRlbnQuIEEgZHJhZyBmcm9tIHJvdyAzIChzY3JvbGxib3gpIGludG8gdGhlXG4gICAgICAgIC8vIGZvb3RlciBhdCByb3cgNiwgdGhlbiByZWxlYXNlLCBsZWF2ZXMgZm9jdXMgb3V0c2lkZSB0aGUgdmlld3BvcnRcbiAgICAgICAgLy8g4oCUIHNoaWZ0U2VsZWN0aW9uRm9yRm9sbG93IHdvdWxkIGNsYW1wIGl0IHRvIHZpZXdwb3J0Qm90dG9tLFxuICAgICAgICAvLyB0ZWxlcG9ydGluZyB0aGUgaGlnaGxpZ2h0IGZyb20gc3RhdGljIGZvb3RlciBpbnRvIHRoZSBzY3JvbGxib3guXG4gICAgICAgIC8vIFN5bW1ldHJpYyBjaGVjazogcmVxdWlyZSBCT1RIIGVuZHMgaW5zaWRlIHRvIHRyYW5zbGF0ZS4gQVxuICAgICAgICAvLyBzdHJhZGRsaW5nIHNlbGVjdGlvbiBmYWxscyB0aHJvdWdoIHRvIE5FSVRIRVIgc2hpZnQgTk9SIGNhcHR1cmU6XG4gICAgICAgIC8vIHRoZSBmb290ZXIgZW5kcG9pbnQgcGlucyB0aGUgc2VsZWN0aW9uLCB0ZXh0IHNjcm9sbHMgYXdheSB1bmRlclxuICAgICAgICAvLyB0aGUgaGlnaGxpZ2h0LCBhbmQgZ2V0U2VsZWN0ZWRUZXh0IHJlYWRzIHRoZSBDVVJSRU5UIHNjcmVlblxuICAgICAgICAvLyBjb250ZW50cyDigJQgbm8gYWNjdW11bGF0aW9uLiBEcmFnZ2luZyBicmFuY2ggZG9lc24ndCBuZWVkIHRoaXM6XG4gICAgICAgIC8vIHNoaWZ0QW5jaG9yIGlnbm9yZXMgZm9jdXMsIGFuZCB0aGUgYW5jaG9yIERPRVMgc2hpZnQgKHNvIGNhcHR1cmVcbiAgICAgICAgLy8gaXMgY29ycmVjdCB0aGVyZSBldmVuIHdoZW4gZm9jdXMgaXMgaW4gdGhlIGZvb3RlcikuXG4gICAgICAgICF0aGlzLnNlbGVjdGlvbi5mb2N1cyB8fFxuICAgICAgICAodGhpcy5zZWxlY3Rpb24uZm9jdXMucm93ID49IHZpZXdwb3J0VG9wICYmXG4gICAgICAgICAgdGhpcy5zZWxlY3Rpb24uZm9jdXMucm93IDw9IHZpZXdwb3J0Qm90dG9tKVxuICAgICAgKSB7XG4gICAgICAgIGlmIChoYXNTZWxlY3Rpb24odGhpcy5zZWxlY3Rpb24pKSB7XG4gICAgICAgICAgY2FwdHVyZVNjcm9sbGVkUm93cyhcbiAgICAgICAgICAgIHRoaXMuc2VsZWN0aW9uLFxuICAgICAgICAgICAgdGhpcy5mcm9udEZyYW1lLnNjcmVlbixcbiAgICAgICAgICAgIHZpZXdwb3J0VG9wLFxuICAgICAgICAgICAgdmlld3BvcnRUb3AgKyBkZWx0YSAtIDEsXG4gICAgICAgICAgICAnYWJvdmUnLFxuICAgICAgICAgIClcbiAgICAgICAgfVxuICAgICAgICBjb25zdCBjbGVhcmVkID0gc2hpZnRTZWxlY3Rpb25Gb3JGb2xsb3coXG4gICAgICAgICAgdGhpcy5zZWxlY3Rpb24sXG4gICAgICAgICAgLWRlbHRhLFxuICAgICAgICAgIHZpZXdwb3J0VG9wLFxuICAgICAgICAgIHZpZXdwb3J0Qm90dG9tLFxuICAgICAgICApXG4gICAgICAgIC8vIEF1dG8tY2xlYXIgKGJvdGggZW5kcyBvdmVyc2hvdCBtaW5Sb3cpIG11c3Qgbm90aWZ5IFJlYWN0LWxhbmRcbiAgICAgICAgLy8gc28gdXNlSGFzU2VsZWN0aW9uIHJlLXJlbmRlcnMgYW5kIHRoZSBmb290ZXIgY29weS9lc2NhcGUgaGludFxuICAgICAgICAvLyBkaXNhcHBlYXJzLiBub3RpZnlTZWxlY3Rpb25DaGFuZ2UoKSB3b3VsZCByZWN1cnNlIGludG8gb25SZW5kZXI7XG4gICAgICAgIC8vIGZpcmUgdGhlIGxpc3RlbmVycyBkaXJlY3RseSDigJQgdGhleSBzY2hlZHVsZSBhIFJlYWN0IHVwZGF0ZSBmb3JcbiAgICAgICAgLy8gTEFURVIsIHRoZXkgZG9uJ3QgcmUtZW50ZXIgdGhpcyBmcmFtZS5cbiAgICAgICAgaWYgKGNsZWFyZWQpIGZvciAoY29uc3QgY2Igb2YgdGhpcy5zZWxlY3Rpb25MaXN0ZW5lcnMpIGNiKClcbiAgICAgIH1cbiAgICB9XG5cbiAgICAvLyBTZWxlY3Rpb24gb3ZlcmxheTogaW52ZXJ0IGNlbGwgc3R5bGVzIGluIHRoZSBzY3JlZW4gYnVmZmVyIGl0c2VsZixcbiAgICAvLyBzbyB0aGUgZGlmZiBwaWNrcyB1cCBzZWxlY3Rpb24gYXMgb3JkaW5hcnkgY2VsbCBjaGFuZ2VzIGFuZFxuICAgIC8vIExvZ1VwZGF0ZSByZW1haW5zIGEgcHVyZSBkaWZmIGVuZ2luZS5cbiAgICAvL1xuICAgIC8vIEZ1bGwtc2NyZWVuIGRhbWFnZSAoUFIgIzIwMTIwKSBpcyBhIGNvcnJlY3RuZXNzIGJhY2tzdG9wIGZvciB0aGVcbiAgICAvLyBzaWJsaW5nLXJlc2l6ZSBibGVlZDogd2hlbiBmbGV4Ym94IHNpYmxpbmdzIHJlc2l6ZSBiZXR3ZWVuIGZyYW1lc1xuICAgIC8vIChzcGlubmVyIGFwcGVhcnMg4oaSIGJvdHRvbSBncm93cyDihpIgc2Nyb2xsYm94IHNocmlua3MpLCB0aGVcbiAgICAvLyBjYWNoZWQtY2xlYXIgKyBjbGlwLWFuZC1jdWxsICsgc2V0Q2VsbEF0IGRhbWFnZSB1bmlvbiBjYW4gbWlzc1xuICAgIC8vIHRyYW5zaXRpb24gY2VsbHMgYXQgdGhlIGJvdW5kYXJ5LiBCdXQgdGhhdCBvbmx5IGhhcHBlbnMgd2hlbiBsYXlvdXRcbiAgICAvLyBhY3R1YWxseSBTSElGVFMg4oCUIGRpZExheW91dFNoaWZ0KCkgdHJhY2tzIGV4YWN0bHkgdGhpcyAoYW55IG5vZGUnc1xuICAgIC8vIGNhY2hlZCB5b2dhIHBvc2l0aW9uL3NpemUgZGlmZmVycyBmcm9tIGN1cnJlbnQsIG9yIGEgY2hpbGQgd2FzXG4gICAgLy8gcmVtb3ZlZCkuIFN0ZWFkeS1zdGF0ZSBmcmFtZXMgKHNwaW5uZXIgcm90YXRlLCBjbG9jayB0aWNrLCB0ZXh0XG4gICAgLy8gc3RyZWFtIGludG8gZml4ZWQtaGVpZ2h0IGJveCkgZG9uJ3Qgc2hpZnQgbGF5b3V0LCBzbyBub3JtYWwgZGFtYWdlXG4gICAgLy8gYm91bmRzIGFyZSBjb3JyZWN0IGFuZCBkaWZmRWFjaCBvbmx5IGNvbXBhcmVzIHRoZSBkYW1hZ2VkIHJlZ2lvbi5cbiAgICAvL1xuICAgIC8vIFNlbGVjdGlvbiBhbHNvIHJlcXVpcmVzIGZ1bGwgZGFtYWdlOiBvdmVybGF5IHdyaXRlcyB2aWEgc2V0Q2VsbFN0eWxlSWRcbiAgICAvLyB3aGljaCBkb2Vzbid0IHRyYWNrIGRhbWFnZSwgYW5kIHByZXYtZnJhbWUgb3ZlcmxheSBjZWxscyBuZWVkIHRvIGJlXG4gICAgLy8gY29tcGFyZWQgd2hlbiBzZWxlY3Rpb24gbW92ZXMvY2xlYXJzLiBwcmV2RnJhbWVDb250YW1pbmF0ZWQgY292ZXJzXG4gICAgLy8gdGhlIGZyYW1lLWFmdGVyLXNlbGVjdGlvbi1jbGVhcnMgY2FzZS5cbiAgICBsZXQgc2VsQWN0aXZlID0gZmFsc2VcbiAgICBsZXQgaGxBY3RpdmUgPSBmYWxzZVxuICAgIGlmICh0aGlzLmFsdFNjcmVlbkFjdGl2ZSkge1xuICAgICAgc2VsQWN0aXZlID0gaGFzU2VsZWN0aW9uKHRoaXMuc2VsZWN0aW9uKVxuICAgICAgaWYgKHNlbEFjdGl2ZSkge1xuICAgICAgICBhcHBseVNlbGVjdGlvbk92ZXJsYXkoZnJhbWUuc2NyZWVuLCB0aGlzLnNlbGVjdGlvbiwgdGhpcy5zdHlsZVBvb2wpXG4gICAgICB9XG4gICAgICAvLyBTY2FuLWhpZ2hsaWdodDogaW52ZXJzZSBvbiBBTEwgdmlzaWJsZSBtYXRjaGVzIChsZXNzL3ZpbSBzdHlsZSkuXG4gICAgICAvLyBQb3NpdGlvbi1oaWdobGlnaHQgKGJlbG93KSBvdmVybGF5cyBDVVJSRU5UICh5ZWxsb3cpIG9uIHRvcC5cbiAgICAgIGhsQWN0aXZlID0gYXBwbHlTZWFyY2hIaWdobGlnaHQoXG4gICAgICAgIGZyYW1lLnNjcmVlbixcbiAgICAgICAgdGhpcy5zZWFyY2hIaWdobGlnaHRRdWVyeSxcbiAgICAgICAgdGhpcy5zdHlsZVBvb2wsXG4gICAgICApXG4gICAgICAvLyBQb3NpdGlvbi1iYXNlZCBDVVJSRU5UOiB3cml0ZSB5ZWxsb3cgYXQgcG9zaXRpb25zW2N1cnJlbnRJZHhdICtcbiAgICAgIC8vIHJvd09mZnNldC4gTm8gc2Nhbm5pbmcg4oCUIHBvc2l0aW9ucyBjYW1lIGZyb20gYSBwcmlvciBzY2FuIHdoZW5cbiAgICAgIC8vIHRoZSBtZXNzYWdlIGZpcnN0IG1vdW50ZWQuIE1lc3NhZ2UtcmVsYXRpdmUgKyByb3dPZmZzZXQgPSBzY3JlZW4uXG4gICAgICBpZiAodGhpcy5zZWFyY2hQb3NpdGlvbnMpIHtcbiAgICAgICAgY29uc3Qgc3AgPSB0aGlzLnNlYXJjaFBvc2l0aW9uc1xuICAgICAgICBjb25zdCBwb3NBcHBsaWVkID0gYXBwbHlQb3NpdGlvbmVkSGlnaGxpZ2h0KFxuICAgICAgICAgIGZyYW1lLnNjcmVlbixcbiAgICAgICAgICB0aGlzLnN0eWxlUG9vbCxcbiAgICAgICAgICBzcC5wb3NpdGlvbnMsXG4gICAgICAgICAgc3Aucm93T2Zmc2V0LFxuICAgICAgICAgIHNwLmN1cnJlbnRJZHgsXG4gICAgICAgIClcbiAgICAgICAgaGxBY3RpdmUgPSBobEFjdGl2ZSB8fCBwb3NBcHBsaWVkXG4gICAgICB9XG4gICAgfVxuXG4gICAgLy8gRnVsbC1kYW1hZ2UgYmFja3N0b3A6IGFwcGxpZXMgb24gQk9USCBhbHQtc2NyZWVuIGFuZCBtYWluLXNjcmVlbi5cbiAgICAvLyBMYXlvdXQgc2hpZnRzIChzcGlubmVyIGFwcGVhcnMsIHN0YXR1cyBsaW5lIHJlc2l6ZXMpIGNhbiBsZWF2ZSBzdGFsZVxuICAgIC8vIGNlbGxzIGF0IHNpYmxpbmcgYm91bmRhcmllcyB0aGF0IHBlci1ub2RlIGRhbWFnZSB0cmFja2luZyBtaXNzZXMuXG4gICAgLy8gU2VsZWN0aW9uL2hpZ2hsaWdodCBvdmVybGF5cyB3cml0ZSB2aWEgc2V0Q2VsbFN0eWxlSWQgd2hpY2ggZG9lc24ndFxuICAgIC8vIHRyYWNrIGRhbWFnZS4gcHJldkZyYW1lQ29udGFtaW5hdGVkIGNvdmVycyB0aGUgY2xlYW51cCBmcmFtZS5cbiAgICBpZiAoXG4gICAgICBkaWRMYXlvdXRTaGlmdCgpIHx8XG4gICAgICBzZWxBY3RpdmUgfHxcbiAgICAgIGhsQWN0aXZlIHx8XG4gICAgICB0aGlzLnByZXZGcmFtZUNvbnRhbWluYXRlZFxuICAgICkge1xuICAgICAgZnJhbWUuc2NyZWVuLmRhbWFnZSA9IHtcbiAgICAgICAgeDogMCxcbiAgICAgICAgeTogMCxcbiAgICAgICAgd2lkdGg6IGZyYW1lLnNjcmVlbi53aWR0aCxcbiAgICAgICAgaGVpZ2h0OiBmcmFtZS5zY3JlZW4uaGVpZ2h0LFxuICAgICAgfVxuICAgIH1cblxuICAgIC8vIEFsdC1zY3JlZW46IGFuY2hvciB0aGUgcGh5c2ljYWwgY3Vyc29yIHRvICgwLDApIGJlZm9yZSBldmVyeSBkaWZmLlxuICAgIC8vIEFsbCBjdXJzb3IgbW92ZXMgaW4gbG9nLXVwZGF0ZSBhcmUgUkVMQVRJVkUgdG8gcHJldi5jdXJzb3I7IGlmIHRtdXhcbiAgICAvLyAob3IgYW55IGVtdWxhdG9yKSBwZXJ0dXJicyB0aGUgcGh5c2ljYWwgY3Vyc29yIG91dC1vZi1iYW5kIChzdGF0dXNcbiAgICAvLyBiYXIgcmVmcmVzaCwgcGFuZSByZWRyYXcsIENtZCtLIHdpcGUpLCB0aGUgcmVsYXRpdmUgbW92ZXMgZHJpZnQgYW5kXG4gICAgLy8gY29udGVudCBjcmVlcHMgdXAgMSByb3cvZnJhbWUuIENTSSBIIHJlc2V0cyB0aGUgcGh5c2ljYWwgY3Vyc29yO1xuICAgIC8vIHBhc3NpbmcgcHJldi5jdXJzb3I9KDAsMCkgbWFrZXMgdGhlIGRpZmYgY29tcHV0ZSBmcm9tIHRoZSBzYW1lIHNwb3QuXG4gICAgLy8gU2VsZi1oZWFsaW5nIGFnYWluc3QgYW55IGV4dGVybmFsIGN1cnNvciBtYW5pcHVsYXRpb24uIE1haW4tc2NyZWVuXG4gICAgLy8gY2FuJ3QgZG8gdGhpcyDigJQgY3Vyc29yLnkgdHJhY2tzIHNjcm9sbGJhY2sgcm93cyBDU0kgSCBjYW4ndCByZWFjaC5cbiAgICAvLyBUaGUgQ1NJIEggd3JpdGUgaXMgZGVmZXJyZWQgdW50aWwgYWZ0ZXIgdGhlIGRpZmYgaXMgY29tcHV0ZWQgc28gd2VcbiAgICAvLyBjYW4gc2tpcCBpdCBmb3IgZW1wdHkgZGlmZnMgKG5vIHdyaXRlcyDihpIgcGh5c2ljYWwgY3Vyc29yIHVudXNlZCkuXG4gICAgbGV0IHByZXZGcmFtZSA9IHRoaXMuZnJvbnRGcmFtZVxuICAgIGlmICh0aGlzLmFsdFNjcmVlbkFjdGl2ZSkge1xuICAgICAgcHJldkZyYW1lID0geyAuLi50aGlzLmZyb250RnJhbWUsIGN1cnNvcjogQUxUX1NDUkVFTl9BTkNIT1JfQ1VSU09SIH1cbiAgICB9XG5cbiAgICBjb25zdCB0RGlmZiA9IHBlcmZvcm1hbmNlLm5vdygpXG4gICAgY29uc3QgZGlmZiA9IHRoaXMubG9nLnJlbmRlcihcbiAgICAgIHByZXZGcmFtZSxcbiAgICAgIGZyYW1lLFxuICAgICAgdGhpcy5hbHRTY3JlZW5BY3RpdmUsXG4gICAgICAvLyBERUNTVEJNIG5lZWRzIEJTVS9FU1UgYXRvbWljaXR5IOKAlCB3aXRob3V0IGl0IHRoZSBvdXRlciB0ZXJtaW5hbFxuICAgICAgLy8gcmVuZGVycyB0aGUgc2Nyb2xsZWQtYnV0LW5vdC15ZXQtcmVwYWludGVkIGludGVybWVkaWF0ZSBzdGF0ZS5cbiAgICAgIC8vIHRtdXggaXMgdGhlIG1haW4gY2FzZSAocmUtZW1pdHMgREVDU1RCTSB3aXRoIGl0cyBvd24gdGltaW5nIGFuZFxuICAgICAgLy8gZG9lc24ndCBpbXBsZW1lbnQgREVDIDIwMjYsIHNvIFNZTkNfT1VUUFVUX1NVUFBPUlRFRCBpcyBmYWxzZSkuXG4gICAgICBTWU5DX09VVFBVVF9TVVBQT1JURUQsXG4gICAgKVxuICAgIGNvbnN0IGRpZmZNcyA9IHBlcmZvcm1hbmNlLm5vdygpIC0gdERpZmZcbiAgICAvLyBTd2FwIGJ1ZmZlcnNcbiAgICB0aGlzLmJhY2tGcmFtZSA9IHRoaXMuZnJvbnRGcmFtZVxuICAgIHRoaXMuZnJvbnRGcmFtZSA9IGZyYW1lXG5cbiAgICAvLyBQZXJpb2RpY2FsbHkgcmVzZXQgY2hhci9oeXBlcmxpbmsgcG9vbHMgdG8gcHJldmVudCB1bmJvdW5kZWQgZ3Jvd3RoXG4gICAgLy8gZHVyaW5nIGxvbmcgc2Vzc2lvbnMuIDUgbWludXRlcyBpcyBpbmZyZXF1ZW50IGVub3VnaCB0aGF0IHRoZSBPKGNlbGxzKVxuICAgIC8vIG1pZ3JhdGlvbiBjb3N0IGlzIG5lZ2xpZ2libGUuIFJldXNlcyByZW5kZXJTdGFydCB0byBhdm9pZCBleHRyYSBjbG9jayBjYWxsLlxuICAgIGlmIChyZW5kZXJTdGFydCAtIHRoaXMubGFzdFBvb2xSZXNldFRpbWUgPiA1ICogNjAgKiAxMDAwKSB7XG4gICAgICB0aGlzLnJlc2V0UG9vbHMoKVxuICAgICAgdGhpcy5sYXN0UG9vbFJlc2V0VGltZSA9IHJlbmRlclN0YXJ0XG4gICAgfVxuXG4gICAgY29uc3QgZmxpY2tlcnM6IEZyYW1lRXZlbnRbJ2ZsaWNrZXJzJ10gPSBbXVxuICAgIGZvciAoY29uc3QgcGF0Y2ggb2YgZGlmZikge1xuICAgICAgaWYgKHBhdGNoLnR5cGUgPT09ICdjbGVhclRlcm1pbmFsJykge1xuICAgICAgICBmbGlja2Vycy5wdXNoKHtcbiAgICAgICAgICBkZXNpcmVkSGVpZ2h0OiBmcmFtZS5zY3JlZW4uaGVpZ2h0LFxuICAgICAgICAgIGF2YWlsYWJsZUhlaWdodDogZnJhbWUudmlld3BvcnQuaGVpZ2h0LFxuICAgICAgICAgIHJlYXNvbjogcGF0Y2gucmVhc29uLFxuICAgICAgICB9KVxuICAgICAgICBpZiAoaXNEZWJ1Z1JlcGFpbnRzRW5hYmxlZCgpICYmIHBhdGNoLmRlYnVnKSB7XG4gICAgICAgICAgY29uc3QgY2hhaW4gPSBkb20uZmluZE93bmVyQ2hhaW5BdFJvdyhcbiAgICAgICAgICAgIHRoaXMucm9vdE5vZGUsXG4gICAgICAgICAgICBwYXRjaC5kZWJ1Zy50cmlnZ2VyWSxcbiAgICAgICAgICApXG4gICAgICAgICAgbG9nRm9yRGVidWdnaW5nKFxuICAgICAgICAgICAgYFtSRVBBSU5UXSBmdWxsIHJlc2V0IMK3ICR7cGF0Y2gucmVhc29ufSDCtyByb3cgJHtwYXRjaC5kZWJ1Zy50cmlnZ2VyWX1cXG5gICtcbiAgICAgICAgICAgICAgYCAgcHJldjogXCIke3BhdGNoLmRlYnVnLnByZXZMaW5lfVwiXFxuYCArXG4gICAgICAgICAgICAgIGAgIG5leHQ6IFwiJHtwYXRjaC5kZWJ1Zy5uZXh0TGluZX1cIlxcbmAgK1xuICAgICAgICAgICAgICBgICBjdWxwcml0OiAke2NoYWluLmxlbmd0aCA/IGNoYWluLmpvaW4oJyA8ICcpIDogJyhubyBvd25lciBjaGFpbiBjYXB0dXJlZCknfWAsXG4gICAgICAgICAgICB7IGxldmVsOiAnd2FybicgfSxcbiAgICAgICAgICApXG4gICAgICAgIH1cbiAgICAgIH1cbiAgICB9XG5cbiAgICBjb25zdCB0T3B0aW1pemUgPSBwZXJmb3JtYW5jZS5ub3coKVxuICAgIGNvbnN0IG9wdGltaXplZCA9IG9wdGltaXplKGRpZmYpXG4gICAgY29uc3Qgb3B0aW1pemVNcyA9IHBlcmZvcm1hbmNlLm5vdygpIC0gdE9wdGltaXplXG4gICAgY29uc3QgaGFzRGlmZiA9IG9wdGltaXplZC5sZW5ndGggPiAwXG4gICAgaWYgKHRoaXMuYWx0U2NyZWVuQWN0aXZlICYmIGhhc0RpZmYpIHtcbiAgICAgIC8vIFByZXBlbmQgQ1NJIEggdG8gYW5jaG9yIHRoZSBwaHlzaWNhbCBjdXJzb3IgdG8gKDAsMCkgc29cbiAgICAgIC8vIGxvZy11cGRhdGUncyByZWxhdGl2ZSBtb3ZlcyBjb21wdXRlIGZyb20gYSBrbm93biBzcG90IChzZWxmLWhlYWxpbmdcbiAgICAgIC8vIGFnYWluc3Qgb3V0LW9mLWJhbmQgY3Vyc29yIGRyaWZ0LCBzZWUgdGhlIEFMVF9TQ1JFRU5fQU5DSE9SX0NVUlNPUlxuICAgICAgLy8gY29tbWVudCBhYm92ZSkuIEFwcGVuZCBDU0kgcm93OzEgSCB0byBwYXJrIHRoZSBjdXJzb3IgYXQgdGhlIGJvdHRvbVxuICAgICAgLy8gcm93ICh3aGVyZSB0aGUgcHJvbXB0IGlucHV0IGlzKSDigJQgd2l0aG91dCB0aGlzLCB0aGUgY3Vyc29yIGVuZHNcbiAgICAgIC8vIHdoZXJldmVyIHRoZSBsYXN0IGRpZmYgd3JpdGUgbGFuZGVkIChhIGRpZmZlcmVudCByb3cgZXZlcnkgZnJhbWUpLFxuICAgICAgLy8gbWFraW5nIGlUZXJtMidzIGN1cnNvciBndWlkZSBmbGlja2VyIGFzIGl0IGNoYXNlcyB0aGUgY3Vyc29yLlxuICAgICAgLy8gQlNVL0VTVSBwcm90ZWN0cyBjb250ZW50IGF0b21pY2l0eSBidXQgaVRlcm0yJ3MgZ3VpZGUgdHJhY2tzIGN1cnNvclxuICAgICAgLy8gcG9zaXRpb24gaW5kZXBlbmRlbnRseS4gUGFya2luZyBhdCBib3R0b20gKG5vdCAwLDApIGtlZXBzIHRoZSBndWlkZVxuICAgICAgLy8gd2hlcmUgdGhlIHVzZXIncyBhdHRlbnRpb24gaXMuXG4gICAgICAvL1xuICAgICAgLy8gQWZ0ZXIgcmVzaXplLCBwcmVwZW5kIEVSQVNFX1NDUkVFTiB0b28uIFRoZSBkaWZmIG9ubHkgd3JpdGVzIGNlbGxzXG4gICAgICAvLyB0aGF0IGNoYW5nZWQ7IGNlbGxzIHdoZXJlIG5ldz1ibGFuayBhbmQgcHJldi1idWZmZXI9YmxhbmsgZ2V0IHNraXBwZWRcbiAgICAgIC8vIOKAlCBidXQgdGhlIHBoeXNpY2FsIHRlcm1pbmFsIHN0aWxsIGhhcyBzdGFsZSBjb250ZW50IHRoZXJlIChzaG9ydGVyXG4gICAgICAvLyBsaW5lcyBhdCBuZXcgd2lkdGggbGVhdmUgb2xkLXdpZHRoIHRleHQgdGFpbHMgdmlzaWJsZSkuIEVSQVNFIGluc2lkZVxuICAgICAgLy8gQlNVL0VTVSBpcyBhdG9taWM6IG9sZCBjb250ZW50IHN0YXlzIHZpc2libGUgdW50aWwgdGhlIHdob2xlXG4gICAgICAvLyBlcmFzZStwYWludCBsYW5kcywgdGhlbiBzd2FwcyBpbiBvbmUgZ28uIFdyaXRpbmcgRVJBU0VfU0NSRUVOXG4gICAgICAvLyBzeW5jaHJvbm91c2x5IGluIGhhbmRsZVJlc2l6ZSB3b3VsZCBibGFuayB0aGUgc2NyZWVuIGZvciB0aGUgfjgwbXNcbiAgICAgIC8vIHJlbmRlcigpIHRha2VzLlxuICAgICAgaWYgKHRoaXMubmVlZHNFcmFzZUJlZm9yZVBhaW50KSB7XG4gICAgICAgIHRoaXMubmVlZHNFcmFzZUJlZm9yZVBhaW50ID0gZmFsc2VcbiAgICAgICAgb3B0aW1pemVkLnVuc2hpZnQoRVJBU0VfVEhFTl9IT01FX1BBVENIKVxuICAgICAgfSBlbHNlIHtcbiAgICAgICAgb3B0aW1pemVkLnVuc2hpZnQoQ1VSU09SX0hPTUVfUEFUQ0gpXG4gICAgICB9XG4gICAgICBvcHRpbWl6ZWQucHVzaCh0aGlzLmFsdFNjcmVlblBhcmtQYXRjaClcbiAgICB9XG5cbiAgICAvLyBOYXRpdmUgY3Vyc29yIHBvc2l0aW9uaW5nOiBwYXJrIHRoZSB0ZXJtaW5hbCBjdXJzb3IgYXQgdGhlIGRlY2xhcmVkXG4gICAgLy8gcG9zaXRpb24gc28gSU1FIHByZWVkaXQgdGV4dCByZW5kZXJzIGlubGluZSBhbmQgc2NyZWVuIHJlYWRlcnMgL1xuICAgIC8vIG1hZ25pZmllcnMgY2FuIGZvbGxvdyB0aGUgaW5wdXQuIG5vZGVDYWNoZSBob2xkcyB0aGUgYWJzb2x1dGUgc2NyZWVuXG4gICAgLy8gcmVjdCBwb3B1bGF0ZWQgYnkgcmVuZGVyTm9kZVRvT3V0cHV0IHRoaXMgZnJhbWUgKGluY2x1ZGluZyBzY3JvbGxUb3BcbiAgICAvLyB0cmFuc2xhdGlvbikg4oCUIGlmIHRoZSBkZWNsYXJlZCBub2RlIGRpZG4ndCByZW5kZXIgKHN0YWxlIGRlY2xhcmF0aW9uXG4gICAgLy8gYWZ0ZXIgcmVtb3VudCwgb3Igc2Nyb2xsZWQgb3V0IG9mIHZpZXcpLCBpdCB3b24ndCBiZSBpbiB0aGUgY2FjaGVcbiAgICAvLyBhbmQgbm8gbW92ZSBpcyBlbWl0dGVkLlxuICAgIGNvbnN0IGRlY2wgPSB0aGlzLmN1cnNvckRlY2xhcmF0aW9uXG4gICAgY29uc3QgcmVjdCA9IGRlY2wgIT09IG51bGwgPyBub2RlQ2FjaGUuZ2V0KGRlY2wubm9kZSkgOiB1bmRlZmluZWRcbiAgICBjb25zdCB0YXJnZXQgPVxuICAgICAgZGVjbCAhPT0gbnVsbCAmJiByZWN0ICE9PSB1bmRlZmluZWRcbiAgICAgICAgPyB7IHg6IHJlY3QueCArIGRlY2wucmVsYXRpdmVYLCB5OiByZWN0LnkgKyBkZWNsLnJlbGF0aXZlWSB9XG4gICAgICAgIDogbnVsbFxuICAgIGNvbnN0IHBhcmtlZCA9IHRoaXMuZGlzcGxheUN1cnNvclxuXG4gICAgLy8gUHJlc2VydmUgdGhlIGVtcHR5LWRpZmYgemVyby13cml0ZSBmYXN0IHBhdGg6IHNraXAgYWxsIGN1cnNvciB3cml0ZXNcbiAgICAvLyB3aGVuIG5vdGhpbmcgcmVuZGVyZWQgQU5EIHRoZSBwYXJrIHRhcmdldCBpcyB1bmNoYW5nZWQuXG4gICAgY29uc3QgdGFyZ2V0TW92ZWQgPVxuICAgICAgdGFyZ2V0ICE9PSBudWxsICYmXG4gICAgICAocGFya2VkID09PSBudWxsIHx8IHBhcmtlZC54ICE9PSB0YXJnZXQueCB8fCBwYXJrZWQueSAhPT0gdGFyZ2V0LnkpXG4gICAgaWYgKGhhc0RpZmYgfHwgdGFyZ2V0TW92ZWQgfHwgKHRhcmdldCA9PT0gbnVsbCAmJiBwYXJrZWQgIT09IG51bGwpKSB7XG4gICAgICAvLyBNYWluLXNjcmVlbiBwcmVhbWJsZTogbG9nLXVwZGF0ZSdzIHJlbGF0aXZlIG1vdmVzIGFzc3VtZSB0aGVcbiAgICAgIC8vIHBoeXNpY2FsIGN1cnNvciBpcyBhdCBwcmV2RnJhbWUuY3Vyc29yLiBJZiBsYXN0IGZyYW1lIHBhcmtlZCBpdFxuICAgICAgLy8gZWxzZXdoZXJlLCBtb3ZlIGJhY2sgYmVmb3JlIHRoZSBkaWZmIHJ1bnMuIEFsdC1zY3JlZW4ncyBDU0kgSFxuICAgICAgLy8gYWxyZWFkeSByZXNldHMgdG8gKDAsMCkgc28gbm8gcHJlYW1ibGUgbmVlZGVkLlxuICAgICAgaWYgKHBhcmtlZCAhPT0gbnVsbCAmJiAhdGhpcy5hbHRTY3JlZW5BY3RpdmUgJiYgaGFzRGlmZikge1xuICAgICAgICBjb25zdCBwZHggPSBwcmV2RnJhbWUuY3Vyc29yLnggLSBwYXJrZWQueFxuICAgICAgICBjb25zdCBwZHkgPSBwcmV2RnJhbWUuY3Vyc29yLnkgLSBwYXJrZWQueVxuICAgICAgICBpZiAocGR4ICE9PSAwIHx8IHBkeSAhPT0gMCkge1xuICAgICAgICAgIG9wdGltaXplZC51bnNoaWZ0KHsgdHlwZTogJ3N0ZG91dCcsIGNvbnRlbnQ6IGN1cnNvck1vdmUocGR4LCBwZHkpIH0pXG4gICAgICAgIH1cbiAgICAgIH1cblxuICAgICAgaWYgKHRhcmdldCAhPT0gbnVsbCkge1xuICAgICAgICBpZiAodGhpcy5hbHRTY3JlZW5BY3RpdmUpIHtcbiAgICAgICAgICAvLyBBYnNvbHV0ZSBDVVAgKDEtaW5kZXhlZCk7IG5leHQgZnJhbWUncyBDU0kgSCByZXNldHMgcmVnYXJkbGVzcy5cbiAgICAgICAgICAvLyBFbWl0dGVkIGFmdGVyIGFsdFNjcmVlblBhcmtQYXRjaCBzbyB0aGUgZGVjbGFyZWQgcG9zaXRpb24gd2lucy5cbiAgICAgICAgICBjb25zdCByb3cgPSBNYXRoLm1pbihNYXRoLm1heCh0YXJnZXQueSArIDEsIDEpLCB0ZXJtaW5hbFJvd3MpXG4gICAgICAgICAgY29uc3QgY29sID0gTWF0aC5taW4oTWF0aC5tYXgodGFyZ2V0LnggKyAxLCAxKSwgdGVybWluYWxXaWR0aClcbiAgICAgICAgICBvcHRpbWl6ZWQucHVzaCh7IHR5cGU6ICdzdGRvdXQnLCBjb250ZW50OiBjdXJzb3JQb3NpdGlvbihyb3csIGNvbCkgfSlcbiAgICAgICAgfSBlbHNlIHtcbiAgICAgICAgICAvLyBBZnRlciB0aGUgZGlmZiAob3IgcHJlYW1ibGUpLCBjdXJzb3IgaXMgYXQgZnJhbWUuY3Vyc29yLiBJZiBub1xuICAgICAgICAgIC8vIGRpZmYgQU5EIHByZXZpb3VzbHkgcGFya2VkLCBpdCdzIHN0aWxsIGF0IHRoZSBvbGQgcGFyayBwb3NpdGlvblxuICAgICAgICAgIC8vIChsb2ctdXBkYXRlIHdyb3RlIG5vdGhpbmcpLiBPdGhlcndpc2UgaXQncyBhdCBmcmFtZS5jdXJzb3IuXG4gICAgICAgICAgY29uc3QgZnJvbSA9XG4gICAgICAgICAgICAhaGFzRGlmZiAmJiBwYXJrZWQgIT09IG51bGxcbiAgICAgICAgICAgICAgPyBwYXJrZWRcbiAgICAgICAgICAgICAgOiB7IHg6IGZyYW1lLmN1cnNvci54LCB5OiBmcmFtZS5jdXJzb3IueSB9XG4gICAgICAgICAgY29uc3QgZHggPSB0YXJnZXQueCAtIGZyb20ueFxuICAgICAgICAgIGNvbnN0IGR5ID0gdGFyZ2V0LnkgLSBmcm9tLnlcbiAgICAgICAgICBpZiAoZHggIT09IDAgfHwgZHkgIT09IDApIHtcbiAgICAgICAgICAgIG9wdGltaXplZC5wdXNoKHsgdHlwZTogJ3N0ZG91dCcsIGNvbnRlbnQ6IGN1cnNvck1vdmUoZHgsIGR5KSB9KVxuICAgICAgICAgIH1cbiAgICAgICAgfVxuICAgICAgICB0aGlzLmRpc3BsYXlDdXJzb3IgPSB0YXJnZXRcbiAgICAgIH0gZWxzZSB7XG4gICAgICAgIC8vIERlY2xhcmF0aW9uIGNsZWFyZWQgKGlucHV0IGJsdXIsIHVubW91bnQpLiBSZXN0b3JlIHBoeXNpY2FsIGN1cnNvclxuICAgICAgICAvLyB0byBmcmFtZS5jdXJzb3IgYmVmb3JlIGZvcmdldHRpbmcgdGhlIHBhcmsgcG9zaXRpb24g4oCUIG90aGVyd2lzZVxuICAgICAgICAvLyBkaXNwbGF5Q3Vyc29yPW51bGwgbGllcyBhYm91dCB3aGVyZSB0aGUgY3Vyc29yIGlzLCBhbmQgdGhlIE5FWFRcbiAgICAgICAgLy8gZnJhbWUncyBwcmVhbWJsZSAob3IgbG9nLXVwZGF0ZSdzIHJlbGF0aXZlIG1vdmVzKSBjb21wdXRlcyBmcm9tIGFcbiAgICAgICAgLy8gd3Jvbmcgc3BvdC4gVGhlIHByZWFtYmxlIGFib3ZlIGhhbmRsZXMgaGFzRGlmZjsgdGhpcyBoYW5kbGVzXG4gICAgICAgIC8vICFoYXNEaWZmIChlLmcuIGFjY2Vzc2liaWxpdHkgbW9kZSB3aGVyZSBibHVyIGRvZXNuJ3QgY2hhbmdlXG4gICAgICAgIC8vIHJlbmRlcmVkVmFsdWUgc2luY2UgaW52ZXJ0IGlzIGlkZW50aXR5KS5cbiAgICAgICAgaWYgKHBhcmtlZCAhPT0gbnVsbCAmJiAhdGhpcy5hbHRTY3JlZW5BY3RpdmUgJiYgIWhhc0RpZmYpIHtcbiAgICAgICAgICBjb25zdCByZHggPSBmcmFtZS5jdXJzb3IueCAtIHBhcmtlZC54XG4gICAgICAgICAgY29uc3QgcmR5ID0gZnJhbWUuY3Vyc29yLnkgLSBwYXJrZWQueVxuICAgICAgICAgIGlmIChyZHggIT09IDAgfHwgcmR5ICE9PSAwKSB7XG4gICAgICAgICAgICBvcHRpbWl6ZWQucHVzaCh7IHR5cGU6ICdzdGRvdXQnLCBjb250ZW50OiBjdXJzb3JNb3ZlKHJkeCwgcmR5KSB9KVxuICAgICAgICAgIH1cbiAgICAgICAgfVxuICAgICAgICB0aGlzLmRpc3BsYXlDdXJzb3IgPSBudWxsXG4gICAgICB9XG4gICAgfVxuXG4gICAgY29uc3QgdFdyaXRlID0gcGVyZm9ybWFuY2Uubm93KClcbiAgICB3cml0ZURpZmZUb1Rlcm1pbmFsKFxuICAgICAgdGhpcy50ZXJtaW5hbCxcbiAgICAgIG9wdGltaXplZCxcbiAgICAgIHRoaXMuYWx0U2NyZWVuQWN0aXZlICYmICFTWU5DX09VVFBVVF9TVVBQT1JURUQsXG4gICAgKVxuICAgIGNvbnN0IHdyaXRlTXMgPSBwZXJmb3JtYW5jZS5ub3coKSAtIHRXcml0ZVxuXG4gICAgLy8gVXBkYXRlIGJsaXQgc2FmZXR5IGZvciB0aGUgTkVYVCBmcmFtZS4gVGhlIGZyYW1lIGp1c3QgcmVuZGVyZWRcbiAgICAvLyBiZWNvbWVzIGZyb250RnJhbWUgKD0gbmV4dCBmcmFtZSdzIHByZXZTY3JlZW4pLiBJZiB3ZSBhcHBsaWVkIHRoZVxuICAgIC8vIHNlbGVjdGlvbiBvdmVybGF5LCB0aGF0IGJ1ZmZlciBoYXMgaW52ZXJ0ZWQgY2VsbHMuIHNlbEFjdGl2ZS9obEFjdGl2ZVxuICAgIC8vIGFyZSBvbmx5IGV2ZXIgdHJ1ZSBpbiBhbHQtc2NyZWVuOyBpbiBtYWluLXNjcmVlbiB0aGlzIGlzIGZhbHNl4oaSZmFsc2UuXG4gICAgdGhpcy5wcmV2RnJhbWVDb250YW1pbmF0ZWQgPSBzZWxBY3RpdmUgfHwgaGxBY3RpdmVcblxuICAgIC8vIEEgU2Nyb2xsQm94IGhhcyBwZW5kaW5nU2Nyb2xsRGVsdGEgbGVmdCB0byBkcmFpbiDigJQgc2NoZWR1bGUgdGhlIG5leHRcbiAgICAvLyBmcmFtZS4gTVVTVCBOT1QgY2FsbCB0aGlzLnNjaGVkdWxlUmVuZGVyKCkgaGVyZTogd2UncmUgaW5zaWRlIGFcbiAgICAvLyB0cmFpbGluZy1lZGdlIHRocm90dGxlIGludm9jYXRpb24sIHRpbWVySWQgaXMgdW5kZWZpbmVkLCBhbmQgbG9kYXNoJ3NcbiAgICAvLyBkZWJvdW5jZSBzZWVzIHRpbWVTaW5jZUxhc3RDYWxsID49IHdhaXQgKGxhc3QgY2FsbCB3YXMgYXQgdGhlIHN0YXJ0XG4gICAgLy8gb2YgdGhpcyB3aW5kb3cpIOKGkiBsZWFkaW5nRWRnZSBmaXJlcyBJTU1FRElBVEVMWSDihpIgZG91YmxlIHJlbmRlciB+MC4xbXNcbiAgICAvLyBhcGFydCDihpIgamFuay4gVXNlIGEgcGxhaW4gdGltZW91dC4gSWYgYSB3aGVlbCBldmVudCBhcnJpdmVzIGZpcnN0LFxuICAgIC8vIGl0cyBzY2hlZHVsZVJlbmRlciBwYXRoIGZpcmVzIGEgcmVuZGVyIHdoaWNoIGNsZWFycyB0aGlzIHRpbWVyIGF0XG4gICAgLy8gdGhlIHRvcCBvZiBvblJlbmRlciDigJQgbm8gZG91YmxlLlxuICAgIC8vXG4gICAgLy8gRHJhaW4gZnJhbWVzIGFyZSBjaGVhcCAoREVDU1RCTSArIH4xMCBwYXRjaGVzLCB+MjAwIGJ5dGVzKSBzbyBydW4gYXRcbiAgICAvLyBxdWFydGVyIGludGVydmFsICh+MjUwZnBzLCBzZXRUaW1lb3V0IHByYWN0aWNhbCBmbG9vcikgZm9yIG1heCBzY3JvbGxcbiAgICAvLyBzcGVlZC4gUmVndWxhciByZW5kZXJzIHN0YXkgYXQgRlJBTUVfSU5URVJWQUxfTVMgdmlhIHRoZSB0aHJvdHRsZS5cbiAgICBpZiAoZnJhbWUuc2Nyb2xsRHJhaW5QZW5kaW5nKSB7XG4gICAgICB0aGlzLmRyYWluVGltZXIgPSBzZXRUaW1lb3V0KFxuICAgICAgICAoKSA9PiB0aGlzLm9uUmVuZGVyKCksXG4gICAgICAgIEZSQU1FX0lOVEVSVkFMX01TID4+IDIsXG4gICAgICApXG4gICAgfVxuXG4gICAgY29uc3QgeW9nYU1zID0gZ2V0TGFzdFlvZ2FNcygpXG4gICAgY29uc3QgY29tbWl0TXMgPSBnZXRMYXN0Q29tbWl0TXMoKVxuICAgIGNvbnN0IHljID0gdGhpcy5sYXN0WW9nYUNvdW50ZXJzXG4gICAgLy8gUmVzZXQgc28gZHJhaW4tb25seSBmcmFtZXMgKG5vIFJlYWN0IGNvbW1pdCkgZG9uJ3QgcmVwZWF0IHN0YWxlIHZhbHVlcy5cbiAgICByZXNldFByb2ZpbGVDb3VudGVycygpXG4gICAgdGhpcy5sYXN0WW9nYUNvdW50ZXJzID0ge1xuICAgICAgbXM6IDAsXG4gICAgICB2aXNpdGVkOiAwLFxuICAgICAgbWVhc3VyZWQ6IDAsXG4gICAgICBjYWNoZUhpdHM6IDAsXG4gICAgICBsaXZlOiAwLFxuICAgIH1cbiAgICB0aGlzLm9wdGlvbnMub25GcmFtZT8uKHtcbiAgICAgIGR1cmF0aW9uTXM6IHBlcmZvcm1hbmNlLm5vdygpIC0gcmVuZGVyU3RhcnQsXG4gICAgICBwaGFzZXM6IHtcbiAgICAgICAgcmVuZGVyZXI6IHJlbmRlcmVyTXMsXG4gICAgICAgIGRpZmY6IGRpZmZNcyxcbiAgICAgICAgb3B0aW1pemU6IG9wdGltaXplTXMsXG4gICAgICAgIHdyaXRlOiB3cml0ZU1zLFxuICAgICAgICBwYXRjaGVzOiBkaWZmLmxlbmd0aCxcbiAgICAgICAgeW9nYTogeW9nYU1zLFxuICAgICAgICBjb21taXQ6IGNvbW1pdE1zLFxuICAgICAgICB5b2dhVmlzaXRlZDogeWMudmlzaXRlZCxcbiAgICAgICAgeW9nYU1lYXN1cmVkOiB5Yy5tZWFzdXJlZCxcbiAgICAgICAgeW9nYUNhY2hlSGl0czogeWMuY2FjaGVIaXRzLFxuICAgICAgICB5b2dhTGl2ZTogeWMubGl2ZSxcbiAgICAgIH0sXG4gICAgICBmbGlja2VycyxcbiAgICB9KVxuICB9XG5cbiAgcGF1c2UoKTogdm9pZCB7XG4gICAgLy8gRmx1c2ggcGVuZGluZyBSZWFjdCB1cGRhdGVzIGFuZCByZW5kZXIgYmVmb3JlIHBhdXNpbmcuXG4gICAgLy8gQHRzLWV4cGVjdC1lcnJvciBmbHVzaFN5bmNGcm9tUmVjb25jaWxlciBleGlzdHMgaW4gcmVhY3QtcmVjb25jaWxlciAwLjMxIGJ1dCBub3QgaW4gQHR5cGVzL3JlYWN0LXJlY29uY2lsZXJcbiAgICByZWNvbmNpbGVyLmZsdXNoU3luY0Zyb21SZWNvbmNpbGVyKClcbiAgICB0aGlzLm9uUmVuZGVyKClcblxuICAgIHRoaXMuaXNQYXVzZWQgPSB0cnVlXG4gIH1cblxuICByZXN1bWUoKTogdm9pZCB7XG4gICAgdGhpcy5pc1BhdXNlZCA9IGZhbHNlXG4gICAgdGhpcy5vblJlbmRlcigpXG4gIH1cblxuICAvKipcbiAgICogUmVzZXQgZnJhbWUgYnVmZmVycyBzbyB0aGUgbmV4dCByZW5kZXIgd3JpdGVzIHRoZSBmdWxsIHNjcmVlbiBmcm9tIHNjcmF0Y2guXG4gICAqIENhbGwgdGhpcyBiZWZvcmUgcmVzdW1lKCkgd2hlbiB0aGUgdGVybWluYWwgY29udGVudCBoYXMgYmVlbiBjb3JydXB0ZWQgYnlcbiAgICogYW4gZXh0ZXJuYWwgcHJvY2VzcyAoZS5nLiB0bXV4LCBzaGVsbCwgZnVsbC1zY3JlZW4gVFVJKS5cbiAgICovXG4gIHJlcGFpbnQoKTogdm9pZCB7XG4gICAgdGhpcy5mcm9udEZyYW1lID0gZW1wdHlGcmFtZShcbiAgICAgIHRoaXMuZnJvbnRGcmFtZS52aWV3cG9ydC5oZWlnaHQsXG4gICAgICB0aGlzLmZyb250RnJhbWUudmlld3BvcnQud2lkdGgsXG4gICAgICB0aGlzLnN0eWxlUG9vbCxcbiAgICAgIHRoaXMuY2hhclBvb2wsXG4gICAgICB0aGlzLmh5cGVybGlua1Bvb2wsXG4gICAgKVxuICAgIHRoaXMuYmFja0ZyYW1lID0gZW1wdHlGcmFtZShcbiAgICAgIHRoaXMuYmFja0ZyYW1lLnZpZXdwb3J0LmhlaWdodCxcbiAgICAgIHRoaXMuYmFja0ZyYW1lLnZpZXdwb3J0LndpZHRoLFxuICAgICAgdGhpcy5zdHlsZVBvb2wsXG4gICAgICB0aGlzLmNoYXJQb29sLFxuICAgICAgdGhpcy5oeXBlcmxpbmtQb29sLFxuICAgIClcbiAgICB0aGlzLmxvZy5yZXNldCgpXG4gICAgLy8gUGh5c2ljYWwgY3Vyc29yIHBvc2l0aW9uIGlzIHVua25vd24gYWZ0ZXIgZXh0ZXJuYWwgdGVybWluYWwgY29ycnVwdGlvbi5cbiAgICAvLyBDbGVhciBkaXNwbGF5Q3Vyc29yIHNvIHRoZSBjdXJzb3IgcHJlYW1ibGUgZG9lc24ndCBlbWl0IGEgc3RhbGVcbiAgICAvLyByZWxhdGl2ZSBtb3ZlIGZyb20gd2hlcmUgd2UgbGFzdCBwYXJrZWQgaXQuXG4gICAgdGhpcy5kaXNwbGF5Q3Vyc29yID0gbnVsbFxuICB9XG5cbiAgLyoqXG4gICAqIENsZWFyIHRoZSBwaHlzaWNhbCB0ZXJtaW5hbCBhbmQgZm9yY2UgYSBmdWxsIHJlZHJhdy5cbiAgICpcbiAgICogVGhlIHRyYWRpdGlvbmFsIHJlYWRsaW5lIGN0cmwrbCDigJQgY2xlYXJzIHRoZSB2aXNpYmxlIHNjcmVlbiBhbmRcbiAgICogcmVkcmF3cyB0aGUgY3VycmVudCBjb250ZW50LiBBbHNvIHRoZSByZWNvdmVyeSBwYXRoIHdoZW4gdGhlIHRlcm1pbmFsXG4gICAqIHdhcyBjbGVhcmVkIGV4dGVybmFsbHkgKG1hY09TIENtZCtLKSBhbmQgSW5rJ3MgZGlmZiBlbmdpbmUgdGhpbmtzXG4gICAqIHVuY2hhbmdlZCBjZWxscyBkb24ndCBuZWVkIHJlcGFpbnRpbmcuIFNjcm9sbGJhY2sgaXMgcHJlc2VydmVkLlxuICAgKi9cbiAgZm9yY2VSZWRyYXcoKTogdm9pZCB7XG4gICAgaWYgKCF0aGlzLm9wdGlvbnMuc3Rkb3V0LmlzVFRZIHx8IHRoaXMuaXNVbm1vdW50ZWQgfHwgdGhpcy5pc1BhdXNlZCkgcmV0dXJuXG4gICAgdGhpcy5vcHRpb25zLnN0ZG91dC53cml0ZShFUkFTRV9TQ1JFRU4gKyBDVVJTT1JfSE9NRSlcbiAgICBpZiAodGhpcy5hbHRTY3JlZW5BY3RpdmUpIHtcbiAgICAgIHRoaXMucmVzZXRGcmFtZXNGb3JBbHRTY3JlZW4oKVxuICAgIH0gZWxzZSB7XG4gICAgICB0aGlzLnJlcGFpbnQoKVxuICAgICAgLy8gcmVwYWludCgpIHJlc2V0cyBmcm9udEZyYW1lIHRvIDDDlzAuIFdpdGhvdXQgdGhpcyBmbGFnIHRoZSBuZXh0XG4gICAgICAvLyBmcmFtZSdzIGJsaXQgb3B0aW1pemF0aW9uIGNvcGllcyBmcm9tIHRoYXQgZW1wdHkgc2NyZWVuIGFuZCB0aGVcbiAgICAgIC8vIGRpZmYgc2VlcyBubyBjb250ZW50LiBvblJlbmRlciByZXNldHMgdGhlIGZsYWcgYXQgZnJhbWUgZW5kLlxuICAgICAgdGhpcy5wcmV2RnJhbWVDb250YW1pbmF0ZWQgPSB0cnVlXG4gICAgfVxuICAgIHRoaXMub25SZW5kZXIoKVxuICB9XG5cbiAgLyoqXG4gICAqIE1hcmsgdGhlIHByZXZpb3VzIGZyYW1lIGFzIHVudHJ1c3R3b3J0aHkgZm9yIGJsaXQsIGZvcmNpbmcgdGhlIG5leHRcbiAgICogcmVuZGVyIHRvIGRvIGEgZnVsbC1kYW1hZ2UgZGlmZiBpbnN0ZWFkIG9mIHRoZSBwZXItbm9kZSBmYXN0IHBhdGguXG4gICAqXG4gICAqIExpZ2h0ZXIgdGhhbiBmb3JjZVJlZHJhdygpIOKAlCBubyBzY3JlZW4gY2xlYXIsIG5vIGV4dHJhIHdyaXRlLiBDYWxsXG4gICAqIGZyb20gYSB1c2VMYXlvdXRFZmZlY3QgY2xlYW51cCB3aGVuIHVubW91bnRpbmcgYSB0YWxsIG92ZXJsYXk6IHRoZVxuICAgKiBibGl0IGZhc3QgcGF0aCBjYW4gY29weSBzdGFsZSBjZWxscyBmcm9tIHRoZSBvdmVybGF5IGZyYW1lIGludG8gcm93c1xuICAgKiB0aGUgc2hydW5rZW4gbGF5b3V0IG5vIGxvbmdlciByZWFjaGVzLCBsZWF2aW5nIGEgZ2hvc3QgdGl0bGUvZGl2aWRlci5cbiAgICogb25SZW5kZXIgcmVzZXRzIHRoZSBmbGFnIGF0IGZyYW1lIGVuZCBzbyBpdCdzIG9uZS1zaG90LlxuICAgKi9cbiAgaW52YWxpZGF0ZVByZXZGcmFtZSgpOiB2b2lkIHtcbiAgICB0aGlzLnByZXZGcmFtZUNvbnRhbWluYXRlZCA9IHRydWVcbiAgfVxuXG4gIC8qKlxuICAgKiBDYWxsZWQgYnkgdGhlIDxBbHRlcm5hdGVTY3JlZW4+IGNvbXBvbmVudCBvbiBtb3VudC91bm1vdW50LlxuICAgKiBDb250cm9scyBjdXJzb3IueSBjbGFtcGluZyBpbiB0aGUgcmVuZGVyZXIgYW5kIGdhdGVzIGFsdC1zY3JlZW4tYXdhcmVcbiAgICogYmVoYXZpb3IgaW4gU0lHQ09OVC9yZXNpemUvdW5tb3VudCBoYW5kbGVycy4gUmVwYWludHMgb24gY2hhbmdlIHNvXG4gICAqIHRoZSBmaXJzdCBhbHQtc2NyZWVuIGZyYW1lIChhbmQgZmlyc3QgbWFpbi1zY3JlZW4gZnJhbWUgb24gZXhpdCkgaXNcbiAgICogYSBmdWxsIHJlZHJhdyB3aXRoIG5vIHN0YWxlIGRpZmYgc3RhdGUuXG4gICAqL1xuICBzZXRBbHRTY3JlZW5BY3RpdmUoYWN0aXZlOiBib29sZWFuLCBtb3VzZVRyYWNraW5nID0gZmFsc2UpOiB2b2lkIHtcbiAgICBpZiAodGhpcy5hbHRTY3JlZW5BY3RpdmUgPT09IGFjdGl2ZSkgcmV0dXJuXG4gICAgdGhpcy5hbHRTY3JlZW5BY3RpdmUgPSBhY3RpdmVcbiAgICB0aGlzLmFsdFNjcmVlbk1vdXNlVHJhY2tpbmcgPSBhY3RpdmUgJiYgbW91c2VUcmFja2luZ1xuICAgIGlmIChhY3RpdmUpIHtcbiAgICAgIHRoaXMucmVzZXRGcmFtZXNGb3JBbHRTY3JlZW4oKVxuICAgIH0gZWxzZSB7XG4gICAgICB0aGlzLnJlcGFpbnQoKVxuICAgIH1cbiAgfVxuXG4gIGdldCBpc0FsdFNjcmVlbkFjdGl2ZSgpOiBib29sZWFuIHtcbiAgICByZXR1cm4gdGhpcy5hbHRTY3JlZW5BY3RpdmVcbiAgfVxuXG4gIC8qKlxuICAgKiBSZS1hc3NlcnQgdGVybWluYWwgbW9kZXMgYWZ0ZXIgYSBnYXAgKD41cyBzdGRpbiBzaWxlbmNlIG9yIGV2ZW50LWxvb3BcbiAgICogc3RhbGwpLiBDYXRjaGVzIHRtdXggZGV0YWNo4oaSYXR0YWNoLCBzc2ggcmVjb25uZWN0LCBhbmQgbGFwdG9wXG4gICAqIHNsZWVwL3dha2Ug4oCUIG5vbmUgb2Ygd2hpY2ggc2VuZCBTSUdDT05ULiBUaGUgdGVybWluYWwgbWF5IHJlc2V0IERFQ1xuICAgKiBwcml2YXRlIG1vZGVzIG9uIHJlY29ubmVjdDsgdGhpcyBtZXRob2QgcmVzdG9yZXMgdGhlbS5cbiAgICpcbiAgICogQWx3YXlzIHJlLWFzc2VydHMgZXh0ZW5kZWQga2V5IHJlcG9ydGluZyBhbmQgbW91c2UgdHJhY2tpbmcuIE1vdXNlXG4gICAqIHRyYWNraW5nIGlzIGlkZW1wb3RlbnQgKERFQyBwcml2YXRlIG1vZGUgc2V0LXdoZW4tc2V0IGlzIGEgbm8tb3ApLiBUaGVcbiAgICogS2l0dHkga2V5Ym9hcmQgcHJvdG9jb2wgaXMgTk9UIOKAlCBDU0kgPjF1IGlzIGEgc3RhY2sgcHVzaCwgc28gd2UgcG9wXG4gICAqIGZpcnN0IHRvIGtlZXAgZGVwdGggYmFsYW5jZWQgKHBvcCBvbiBlbXB0eSBzdGFjayBpcyBhIG5vLW9wIHBlciBzcGVjLFxuICAgKiBzbyBhZnRlciBhIHRlcm1pbmFsIHJlc2V0IHRoaXMgc3RpbGwgcmVzdG9yZXMgZGVwdGggMOKGkjEpLiBXaXRob3V0IHRoZVxuICAgKiBwb3AsIGVhY2ggPjVzIGlkbGUgZ2FwIGFkZHMgYSBzdGFjayBlbnRyeSwgYW5kIHRoZSBzaW5nbGUgcG9wIG9uIGV4aXRcbiAgICogb3Igc3VzcGVuZCBjYW4ndCBkcmFpbiB0aGVtIOKAlCB0aGUgc2hlbGwgaXMgbGVmdCBpbiBDU0kgdSBtb2RlIHdoZXJlXG4gICAqIEN0cmwrQy9DdHJsK0QgbGVhayBhcyBlc2NhcGUgc2VxdWVuY2VzLiBUaGUgYWx0LXNjcmVlblxuICAgKiByZS1lbnRyeSAoRVJBU0VfU0NSRUVOICsgZnJhbWUgcmVzZXQpIGlzIE5PVCBpZGVtcG90ZW50IOKAlCBpdCBibGFua3MgdGhlXG4gICAqIHNjcmVlbiDigJQgc28gaXQncyBvcHQtaW4gdmlhIGluY2x1ZGVBbHRTY3JlZW4uIFRoZSBzdGRpbi1nYXAgY2FsbGVyIGZpcmVzXG4gICAqIG9uIG9yZGluYXJ5ID41cyBpZGxlICsga2V5cHJlc3MgYW5kIG11c3Qgbm90IGVyYXNlOyB0aGUgZXZlbnQtbG9vcCBzdGFsbFxuICAgKiBkZXRlY3RvciBmaXJlcyBvbiBnZW51aW5lIHNsZWVwL3dha2UgYW5kIG9wdHMgaW4uIHRtdXggYXR0YWNoIC8gc3NoXG4gICAqIHJlY29ubmVjdCB0eXBpY2FsbHkgc2VuZCBhIHJlc2l6ZSwgd2hpY2ggYWxyZWFkeSBjb3ZlcnMgYWx0LXNjcmVlbiB2aWFcbiAgICogaGFuZGxlUmVzaXplLlxuICAgKi9cbiAgcmVhc3NlcnRUZXJtaW5hbE1vZGVzID0gKGluY2x1ZGVBbHRTY3JlZW4gPSBmYWxzZSk6IHZvaWQgPT4ge1xuICAgIGlmICghdGhpcy5vcHRpb25zLnN0ZG91dC5pc1RUWSkgcmV0dXJuXG4gICAgLy8gRG9uJ3QgdG91Y2ggdGhlIHRlcm1pbmFsIGR1cmluZyBhbiBlZGl0b3IgaGFuZG9mZiDigJQgcmUtZW5hYmxpbmcga2l0dHlcbiAgICAvLyBrZXlib2FyZCBoZXJlIHdvdWxkIHVuZG8gZW50ZXJBbHRlcm5hdGVTY3JlZW4ncyBkaXNhYmxlIGFuZCBuYW5vIHdvdWxkXG4gICAgLy8gc3RhcnQgc2VlaW5nIENTSS11IHNlcXVlbmNlcyBhZ2Fpbi5cbiAgICBpZiAodGhpcy5pc1BhdXNlZCkgcmV0dXJuXG4gICAgLy8gRXh0ZW5kZWQga2V5cyDigJQgcmUtYXNzZXJ0IGlmIGVuYWJsZWQgKEFwcC50c3ggZW5hYmxlcyB0aGVzZSBvblxuICAgIC8vIGFsbG93bGlzdGVkIHRlcm1pbmFscyBhdCByYXctbW9kZSBlbnRyeTsgYSB0ZXJtaW5hbCByZXNldCBjbGVhcnMgdGhlbSkuXG4gICAgLy8gUG9wLWJlZm9yZS1wdXNoIGtlZXBzIEtpdHR5IHN0YWNrIGRlcHRoIGF0IDEgaW5zdGVhZCBvZiBhY2N1bXVsYXRpbmdcbiAgICAvLyBvbiBlYWNoIGNhbGwuXG4gICAgaWYgKHN1cHBvcnRzRXh0ZW5kZWRLZXlzKCkpIHtcbiAgICAgIHRoaXMub3B0aW9ucy5zdGRvdXQud3JpdGUoXG4gICAgICAgIERJU0FCTEVfS0lUVFlfS0VZQk9BUkQgK1xuICAgICAgICAgIEVOQUJMRV9LSVRUWV9LRVlCT0FSRCArXG4gICAgICAgICAgRU5BQkxFX01PRElGWV9PVEhFUl9LRVlTLFxuICAgICAgKVxuICAgIH1cbiAgICBpZiAoIXRoaXMuYWx0U2NyZWVuQWN0aXZlKSByZXR1cm5cbiAgICAvLyBNb3VzZSB0cmFja2luZyDigJQgaWRlbXBvdGVudCwgc2FmZSB0byByZS1hc3NlcnQgb24gZXZlcnkgc3RkaW4gZ2FwLlxuICAgIGlmICh0aGlzLmFsdFNjcmVlbk1vdXNlVHJhY2tpbmcpIHtcbiAgICAgIHRoaXMub3B0aW9ucy5zdGRvdXQud3JpdGUoRU5BQkxFX01PVVNFX1RSQUNLSU5HKVxuICAgIH1cbiAgICAvLyBBbHQtc2NyZWVuIHJlLWVudHJ5IOKAlCBkZXN0cnVjdGl2ZSAoRVJBU0VfU0NSRUVOKS4gT25seSBmb3IgY2FsbGVycyB0aGF0XG4gICAgLy8gaGF2ZSBhIHN0cm9uZyBzaWduYWwgdGhlIHRlcm1pbmFsIGFjdHVhbGx5IGRyb3BwZWQgbW9kZSAxMDQ5LlxuICAgIGlmIChpbmNsdWRlQWx0U2NyZWVuKSB7XG4gICAgICB0aGlzLnJlZW50ZXJBbHRTY3JlZW4oKVxuICAgIH1cbiAgfVxuXG4gIC8qKlxuICAgKiBNYXJrIHRoaXMgaW5zdGFuY2UgYXMgdW5tb3VudGVkIHNvIGZ1dHVyZSB1bm1vdW50KCkgY2FsbHMgZWFybHktcmV0dXJuLlxuICAgKiBDYWxsZWQgYnkgZ3JhY2VmdWxTaHV0ZG93bidzIGNsZWFudXBUZXJtaW5hbE1vZGVzKCkgYWZ0ZXIgaXQgaGFzIHNlbnRcbiAgICogRVhJVF9BTFRfU0NSRUVOIGJ1dCBiZWZvcmUgdGhlIHJlbWFpbmluZyB0ZXJtaW5hbC1yZXNldCBzZXF1ZW5jZXMuXG4gICAqIFdpdGhvdXQgdGhpcywgc2lnbmFsLWV4aXQncyBkZWZlcnJlZCBpbmsudW5tb3VudCgpICh0cmlnZ2VyZWQgYnlcbiAgICogcHJvY2Vzcy5leGl0KCkpIHJ1bnMgdGhlIGZ1bGwgdW5tb3VudCBwYXRoOiBvblJlbmRlcigpICsgd3JpdGVTeW5jXG4gICAqIGNsZWFudXAgYmxvY2sgKyB1cGRhdGVDb250YWluZXJTeW5jIOKGkiBBbHRlcm5hdGVTY3JlZW4gdW5tb3VudCBjbGVhbnVwLlxuICAgKiBUaGUgcmVzdWx0IGlzIDItMyByZWR1bmRhbnQgRVhJVF9BTFRfU0NSRUVOIHNlcXVlbmNlcyBsYW5kaW5nIG9uIHRoZVxuICAgKiBtYWluIHNjcmVlbiBBRlRFUiBwcmludFJlc3VtZUhpbnQoKSwgd2hpY2ggdG11eCAoYXQgbGVhc3QpIGludGVycHJldHNcbiAgICogYXMgcmVzdG9yaW5nIHRoZSBzYXZlZCBjdXJzb3IgcG9zaXRpb24g4oCUIGNsb2JiZXJpbmcgdGhlIHJlc3VtZSBoaW50LlxuICAgKi9cbiAgZGV0YWNoRm9yU2h1dGRvd24oKTogdm9pZCB7XG4gICAgdGhpcy5pc1VubW91bnRlZCA9IHRydWVcbiAgICAvLyBDYW5jZWwgYW55IHBlbmRpbmcgdGhyb3R0bGVkIHJlbmRlciBzbyBpdCBkb2Vzbid0IGZpcmUgYmV0d2VlblxuICAgIC8vIGNsZWFudXBUZXJtaW5hbE1vZGVzKCkgYW5kIHByb2Nlc3MuZXhpdCgpIGFuZCB3cml0ZSB0byBtYWluIHNjcmVlbi5cbiAgICB0aGlzLnNjaGVkdWxlUmVuZGVyLmNhbmNlbD8uKClcbiAgICAvLyBSZXN0b3JlIHN0ZGluIGZyb20gcmF3IG1vZGUuIHVubW91bnQoKSB1c2VkIHRvIGRvIHRoaXMgdmlhIFJlYWN0XG4gICAgLy8gdW5tb3VudCAoQXBwLmNvbXBvbmVudFdpbGxVbm1vdW50IOKGkiBoYW5kbGVTZXRSYXdNb2RlKGZhbHNlKSkgYnV0IHdlJ3JlXG4gICAgLy8gc2hvcnQtY2lyY3VpdGluZyB0aGF0IHBhdGguIE11c3QgdXNlIHRoaXMub3B0aW9ucy5zdGRpbiDigJQgTk9UXG4gICAgLy8gcHJvY2Vzcy5zdGRpbiDigJQgYmVjYXVzZSBnZXRTdGRpbk92ZXJyaWRlKCkgbWF5IGhhdmUgb3BlbmVkIC9kZXYvdHR5XG4gICAgLy8gd2hlbiBzdGRpbiBpcyBwaXBlZC5cbiAgICBjb25zdCBzdGRpbiA9IHRoaXMub3B0aW9ucy5zdGRpbiBhcyBOb2RlSlMuUmVhZFN0cmVhbSAmIHtcbiAgICAgIGlzUmF3PzogYm9vbGVhblxuICAgICAgc2V0UmF3TW9kZT86IChtOiBib29sZWFuKSA9PiB2b2lkXG4gICAgfVxuICAgIHRoaXMuZHJhaW5TdGRpbigpXG4gICAgaWYgKHN0ZGluLmlzVFRZICYmIHN0ZGluLmlzUmF3ICYmIHN0ZGluLnNldFJhd01vZGUpIHtcbiAgICAgIHN0ZGluLnNldFJhd01vZGUoZmFsc2UpXG4gICAgfVxuICB9XG5cbiAgLyoqIEBzZWUgZHJhaW5TdGRpbiAqL1xuICBkcmFpblN0ZGluKCk6IHZvaWQge1xuICAgIGRyYWluU3RkaW4odGhpcy5vcHRpb25zLnN0ZGluKVxuICB9XG5cbiAgLyoqXG4gICAqIFJlLWVudGVyIGFsdC1zY3JlZW4sIGNsZWFyLCBob21lLCByZS1lbmFibGUgbW91c2UgdHJhY2tpbmcsIGFuZCByZXNldFxuICAgKiBmcmFtZSBidWZmZXJzIHNvIHRoZSBuZXh0IHJlbmRlciByZXBhaW50cyBmcm9tIHNjcmF0Y2guIFNlbGYtaGVhbCBmb3JcbiAgICogU0lHQ09OVCwgcmVzaXplLCBhbmQgc3RkaW4tZ2FwL2V2ZW50LWxvb3Atc3RhbGwgKHNsZWVwL3dha2UpIOKAlCBhbnkgb2ZcbiAgICogd2hpY2ggY2FuIGxlYXZlIHRoZSB0ZXJtaW5hbCBpbiBtYWluLXNjcmVlbiBtb2RlIHdoaWxlIGFsdFNjcmVlbkFjdGl2ZVxuICAgKiBzdGF5cyB0cnVlLiBFTlRFUl9BTFRfU0NSRUVOIGlzIGEgdGVybWluYWwtc2lkZSBuby1vcCBpZiBhbHJlYWR5IGluIGFsdC5cbiAgICovXG4gIHByaXZhdGUgcmVlbnRlckFsdFNjcmVlbigpOiB2b2lkIHtcbiAgICB0aGlzLm9wdGlvbnMuc3Rkb3V0LndyaXRlKFxuICAgICAgRU5URVJfQUxUX1NDUkVFTiArXG4gICAgICAgIEVSQVNFX1NDUkVFTiArXG4gICAgICAgIENVUlNPUl9IT01FICtcbiAgICAgICAgKHRoaXMuYWx0U2NyZWVuTW91c2VUcmFja2luZyA/IEVOQUJMRV9NT1VTRV9UUkFDS0lORyA6ICcnKSxcbiAgICApXG4gICAgdGhpcy5yZXNldEZyYW1lc0ZvckFsdFNjcmVlbigpXG4gIH1cblxuICAvKipcbiAgICogU2VlZCBwcmV2L2JhY2sgZnJhbWVzIHdpdGggZnVsbC1zaXplIEJMQU5LIHNjcmVlbnMgKHJvd3PDl2NvbHMgb2YgZW1wdHlcbiAgICogY2VsbHMsIG5vdCAww5cwKS4gSW4gYWx0LXNjcmVlbiBtb2RlLCBuZXh0LnNjcmVlbi5oZWlnaHQgaXMgYWx3YXlzXG4gICAqIHRlcm1pbmFsUm93czsgaWYgcHJldi5zY3JlZW4uaGVpZ2h0IGlzIDAgKGVtcHR5RnJhbWUncyBkZWZhdWx0KSxcbiAgICogbG9nLXVwZGF0ZSBzZWVzIGhlaWdodERlbHRhID4gMCAoJ2dyb3dpbmcnKSBhbmQgY2FsbHMgcmVuZGVyRnJhbWVTbGljZSxcbiAgICogd2hvc2UgdHJhaWxpbmcgcGVyLXJvdyBDUitMRiBhdCB0aGUgbGFzdCByb3cgc2Nyb2xscyB0aGUgYWx0IHNjcmVlbixcbiAgICogcGVybWFuZW50bHkgZGVzeW5jaW5nIHRoZSB2aXJ0dWFsIGFuZCBwaHlzaWNhbCBjdXJzb3JzIGJ5IDEgcm93LlxuICAgKlxuICAgKiBXaXRoIGEgcm93c8OXY29scyBibGFuayBwcmV2LCBoZWlnaHREZWx0YSA9PT0gMCDihpIgc3RhbmRhcmQgZGlmZkVhY2hcbiAgICog4oaSIG1vdmVDdXJzb3JUbyAoQ1NJIGN1cnNvck1vdmUsIG5vIExGLCBubyBzY3JvbGwpLlxuICAgKlxuICAgKiB2aWV3cG9ydC5oZWlnaHQgPSByb3dzICsgMSBtYXRjaGVzIHRoZSByZW5kZXJlcidzIGFsdC1zY3JlZW4gb3V0cHV0LFxuICAgKiBwcmV2ZW50aW5nIGEgc3B1cmlvdXMgcmVzaXplIHRyaWdnZXIgb24gdGhlIGZpcnN0IGZyYW1lLiBjdXJzb3IueSA9IDBcbiAgICogbWF0Y2hlcyB0aGUgcGh5c2ljYWwgY3Vyc29yIGFmdGVyIEVOVEVSX0FMVF9TQ1JFRU4gKyBDU0kgSCAoaG9tZSkuXG4gICAqL1xuICBwcml2YXRlIHJlc2V0RnJhbWVzRm9yQWx0U2NyZWVuKCk6IHZvaWQge1xuICAgIGNvbnN0IHJvd3MgPSB0aGlzLnRlcm1pbmFsUm93c1xuICAgIGNvbnN0IGNvbHMgPSB0aGlzLnRlcm1pbmFsQ29sdW1uc1xuICAgIGNvbnN0IGJsYW5rID0gKCk6IEZyYW1lID0+ICh7XG4gICAgICBzY3JlZW46IGNyZWF0ZVNjcmVlbihcbiAgICAgICAgY29scyxcbiAgICAgICAgcm93cyxcbiAgICAgICAgdGhpcy5zdHlsZVBvb2wsXG4gICAgICAgIHRoaXMuY2hhclBvb2wsXG4gICAgICAgIHRoaXMuaHlwZXJsaW5rUG9vbCxcbiAgICAgICksXG4gICAgICB2aWV3cG9ydDogeyB3aWR0aDogY29scywgaGVpZ2h0OiByb3dzICsgMSB9LFxuICAgICAgY3Vyc29yOiB7IHg6IDAsIHk6IDAsIHZpc2libGU6IHRydWUgfSxcbiAgICB9KVxuICAgIHRoaXMuZnJvbnRGcmFtZSA9IGJsYW5rKClcbiAgICB0aGlzLmJhY2tGcmFtZSA9IGJsYW5rKClcbiAgICB0aGlzLmxvZy5yZXNldCgpXG4gICAgLy8gRGVmZW5zZS1pbi1kZXB0aDogYWx0LXNjcmVlbiBza2lwcyB0aGUgY3Vyc29yIHByZWFtYmxlIGFueXdheSAoQ1NJIEhcbiAgICAvLyByZXNldHMpLCBidXQgYSBzdGFsZSBkaXNwbGF5Q3Vyc29yIHdvdWxkIGJlIG1pc2xlYWRpbmcgaWYgd2UgbGF0ZXJcbiAgICAvLyBleGl0IHRvIG1haW4tc2NyZWVuIHdpdGhvdXQgYW4gaW50ZXJ2ZW5pbmcgcmVuZGVyLlxuICAgIHRoaXMuZGlzcGxheUN1cnNvciA9IG51bGxcbiAgICAvLyBGcmVzaCBmcm9udEZyYW1lIGlzIGJsYW5rIHJvd3PDl2NvbHMg4oCUIGJsaXR0aW5nIGZyb20gaXQgd291bGQgY29weVxuICAgIC8vIGJsYW5rcyBvdmVyIGNvbnRlbnQuIE5leHQgYWx0LXNjcmVlbiBmcmFtZSBtdXN0IGZ1bGwtcmVuZGVyLlxuICAgIHRoaXMucHJldkZyYW1lQ29udGFtaW5hdGVkID0gdHJ1ZVxuICB9XG5cbiAgLyoqXG4gICAqIENvcHkgdGhlIGN1cnJlbnQgc2VsZWN0aW9uIHRvIHRoZSBjbGlwYm9hcmQgd2l0aG91dCBjbGVhcmluZyB0aGVcbiAgICogaGlnaGxpZ2h0LiBNYXRjaGVzIGlUZXJtMidzIGNvcHktb24tc2VsZWN0IGJlaGF2aW9yIHdoZXJlIHRoZSBzZWxlY3RlZFxuICAgKiByZWdpb24gc3RheXMgdmlzaWJsZSBhZnRlciB0aGUgYXV0b21hdGljIGNvcHkuXG4gICAqL1xuICBjb3B5U2VsZWN0aW9uTm9DbGVhcigpOiBzdHJpbmcge1xuICAgIGlmICghaGFzU2VsZWN0aW9uKHRoaXMuc2VsZWN0aW9uKSkgcmV0dXJuICcnXG4gICAgY29uc3QgdGV4dCA9IGdldFNlbGVjdGVkVGV4dCh0aGlzLnNlbGVjdGlvbiwgdGhpcy5mcm9udEZyYW1lLnNjcmVlbilcbiAgICBpZiAodGV4dCkge1xuICAgICAgLy8gUmF3IE9TQyA1Miwgb3IgRENTLXBhc3N0aHJvdWdoLXdyYXBwZWQgT1NDIDUyIGluc2lkZSB0bXV4ICh0bXV4XG4gICAgICAvLyBkcm9wcyBpdCBzaWxlbnRseSB1bmxlc3MgYWxsb3ctcGFzc3Rocm91Z2ggaXMgb24g4oCUIG5vIHJlZ3Jlc3Npb24pLlxuICAgICAgdm9pZCBzZXRDbGlwYm9hcmQodGV4dCkudGhlbihyYXcgPT4ge1xuICAgICAgICBpZiAocmF3KSB0aGlzLm9wdGlvbnMuc3Rkb3V0LndyaXRlKHJhdylcbiAgICAgIH0pXG4gICAgfVxuICAgIHJldHVybiB0ZXh0XG4gIH1cblxuICAvKipcbiAgICogQ29weSB0aGUgY3VycmVudCB0ZXh0IHNlbGVjdGlvbiB0byB0aGUgc3lzdGVtIGNsaXBib2FyZCB2aWEgT1NDIDUyXG4gICAqIGFuZCBjbGVhciB0aGUgc2VsZWN0aW9uLiBSZXR1cm5zIHRoZSBjb3BpZWQgdGV4dCAoZW1wdHkgaWYgbm8gc2VsZWN0aW9uKS5cbiAgICovXG4gIGNvcHlTZWxlY3Rpb24oKTogc3RyaW5nIHtcbiAgICBpZiAoIWhhc1NlbGVjdGlvbih0aGlzLnNlbGVjdGlvbikpIHJldHVybiAnJ1xuICAgIGNvbnN0IHRleHQgPSB0aGlzLmNvcHlTZWxlY3Rpb25Ob0NsZWFyKClcbiAgICBjbGVhclNlbGVjdGlvbih0aGlzLnNlbGVjdGlvbilcbiAgICB0aGlzLm5vdGlmeVNlbGVjdGlvbkNoYW5nZSgpXG4gICAgcmV0dXJuIHRleHRcbiAgfVxuXG4gIC8qKiBDbGVhciB0aGUgY3VycmVudCB0ZXh0IHNlbGVjdGlvbiB3aXRob3V0IGNvcHlpbmcuICovXG4gIGNsZWFyVGV4dFNlbGVjdGlvbigpOiB2b2lkIHtcbiAgICBpZiAoIWhhc1NlbGVjdGlvbih0aGlzLnNlbGVjdGlvbikpIHJldHVyblxuICAgIGNsZWFyU2VsZWN0aW9uKHRoaXMuc2VsZWN0aW9uKVxuICAgIHRoaXMubm90aWZ5U2VsZWN0aW9uQ2hhbmdlKClcbiAgfVxuXG4gIC8qKlxuICAgKiBTZXQgdGhlIHNlYXJjaCBoaWdobGlnaHQgcXVlcnkuIE5vbi1lbXB0eSDihpIgYWxsIHZpc2libGUgb2NjdXJyZW5jZXNcbiAgICogYXJlIGludmVydGVkIChTR1IgNykgb24gdGhlIG5leHQgZnJhbWU7IGZpcnN0IG9uZSBhbHNvIHVuZGVybGluZWQuXG4gICAqIEVtcHR5IOKGkiBjbGVhcnMgKHByZXZGcmFtZUNvbnRhbWluYXRlZCBoYW5kbGVzIHRoZSBmcmFtZSBhZnRlcikuIFNhbWVcbiAgICogZGFtYWdlLXRyYWNraW5nIG1hY2hpbmVyeSBhcyBzZWxlY3Rpb24g4oCUIHNldENlbGxTdHlsZUlkIGRvZXNuJ3QgdHJhY2tcbiAgICogZGFtYWdlLCBzbyB0aGUgb3ZlcmxheSBmb3JjZXMgZnVsbC1mcmFtZSBkYW1hZ2Ugd2hpbGUgYWN0aXZlLlxuICAgKi9cbiAgc2V0U2VhcmNoSGlnaGxpZ2h0KHF1ZXJ5OiBzdHJpbmcpOiB2b2lkIHtcbiAgICBpZiAodGhpcy5zZWFyY2hIaWdobGlnaHRRdWVyeSA9PT0gcXVlcnkpIHJldHVyblxuICAgIHRoaXMuc2VhcmNoSGlnaGxpZ2h0UXVlcnkgPSBxdWVyeVxuICAgIHRoaXMuc2NoZWR1bGVSZW5kZXIoKVxuICB9XG5cbiAgLyoqIFBhaW50IGFuIEVYSVNUSU5HIERPTSBzdWJ0cmVlIHRvIGEgZnJlc2ggU2NyZWVuIGF0IGl0cyBuYXR1cmFsXG4gICAqICBoZWlnaHQsIHNjYW4gZm9yIHF1ZXJ5LiBSZXR1cm5zIHBvc2l0aW9ucyByZWxhdGl2ZSB0byB0aGUgZWxlbWVudCdzXG4gICAqICBib3VuZGluZyBib3ggKHJvdyAwID0gZWxlbWVudCB0b3ApLlxuICAgKlxuICAgKiAgVGhlIGVsZW1lbnQgY29tZXMgZnJvbSB0aGUgTUFJTiB0cmVlIOKAlCBidWlsdCB3aXRoIGFsbCByZWFsXG4gICAqICBwcm92aWRlcnMsIHlvZ2EgYWxyZWFkeSBjb21wdXRlZC4gV2UgcGFpbnQgaXQgdG8gYSBmcmVzaCBidWZmZXJcbiAgICogIHdpdGggb2Zmc2V0cyBzbyBpdCBsYW5kcyBhdCAoMCwwKS4gU2FtZSBwYWludCBwYXRoIGFzIHRoZSBtYWluXG4gICAqICByZW5kZXIuIFplcm8gZHJpZnQuIE5vIHNlY29uZCBSZWFjdCByb290LCBubyBjb250ZXh0IGJyaWRnZS5cbiAgICpcbiAgICogIH4xLTJtcyAocGFpbnQgb25seSwgbm8gcmVjb25jaWxlIOKAlCB0aGUgRE9NIGlzIGFscmVhZHkgYnVpbHQpLiAqL1xuICBzY2FuRWxlbWVudFN1YnRyZWUoZWw6IGRvbS5ET01FbGVtZW50KTogTWF0Y2hQb3NpdGlvbltdIHtcbiAgICBpZiAoIXRoaXMuc2VhcmNoSGlnaGxpZ2h0UXVlcnkgfHwgIWVsLnlvZ2FOb2RlKSByZXR1cm4gW11cbiAgICBjb25zdCB3aWR0aCA9IE1hdGguY2VpbChlbC55b2dhTm9kZS5nZXRDb21wdXRlZFdpZHRoKCkpXG4gICAgY29uc3QgaGVpZ2h0ID0gTWF0aC5jZWlsKGVsLnlvZ2FOb2RlLmdldENvbXB1dGVkSGVpZ2h0KCkpXG4gICAgaWYgKHdpZHRoIDw9IDAgfHwgaGVpZ2h0IDw9IDApIHJldHVybiBbXVxuICAgIC8vIHJlbmRlck5vZGVUb091dHB1dCBhZGRzIGVsJ3MgT1dOIGNvbXB1dGVkTGVmdC9Ub3AgdG8gb2Zmc2V0WC9ZLlxuICAgIC8vIFBhc3NpbmcgLWVsTGVmdC8tZWxUb3AgbmV0cyB0byAwIOKGkiBwYWludHMgYXQgKDAsMCkgaW4gb3VyIGJ1ZmZlci5cbiAgICBjb25zdCBlbExlZnQgPSBlbC55b2dhTm9kZS5nZXRDb21wdXRlZExlZnQoKVxuICAgIGNvbnN0IGVsVG9wID0gZWwueW9nYU5vZGUuZ2V0Q29tcHV0ZWRUb3AoKVxuICAgIGNvbnN0IHNjcmVlbiA9IGNyZWF0ZVNjcmVlbihcbiAgICAgIHdpZHRoLFxuICAgICAgaGVpZ2h0LFxuICAgICAgdGhpcy5zdHlsZVBvb2wsXG4gICAgICB0aGlzLmNoYXJQb29sLFxuICAgICAgdGhpcy5oeXBlcmxpbmtQb29sLFxuICAgIClcbiAgICBjb25zdCBvdXRwdXQgPSBuZXcgT3V0cHV0KHtcbiAgICAgIHdpZHRoLFxuICAgICAgaGVpZ2h0LFxuICAgICAgc3R5bGVQb29sOiB0aGlzLnN0eWxlUG9vbCxcbiAgICAgIHNjcmVlbixcbiAgICB9KVxuICAgIHJlbmRlck5vZGVUb091dHB1dChlbCwgb3V0cHV0LCB7XG4gICAgICBvZmZzZXRYOiAtZWxMZWZ0LFxuICAgICAgb2Zmc2V0WTogLWVsVG9wLFxuICAgICAgcHJldlNjcmVlbjogdW5kZWZpbmVkLFxuICAgIH0pXG4gICAgY29uc3QgcmVuZGVyZWQgPSBvdXRwdXQuZ2V0KClcbiAgICAvLyByZW5kZXJOb2RlVG9PdXRwdXQgd3JvdGUgb3VyIG9mZnNldCBwb3NpdGlvbnMgdG8gbm9kZUNhY2hlIOKAlFxuICAgIC8vIGNvcnJ1cHRzIHRoZSBtYWluIHJlbmRlciAoaXQnZCBibGl0IGZyb20gd3JvbmcgY29vcmRzKS4gTWFyayB0aGVcbiAgICAvLyBzdWJ0cmVlIGRpcnR5IHNvIHRoZSBuZXh0IG1haW4gcmVuZGVyIHJlcGFpbnRzICsgcmUtY2FjaGVzXG4gICAgLy8gY29ycmVjdGx5LiBPbmUgZXh0cmEgcGFpbnQgb2YgdGhpcyBtZXNzYWdlLCBidXQgY29ycmVjdCA+IGZhc3QuXG4gICAgZG9tLm1hcmtEaXJ0eShlbClcbiAgICBjb25zdCBwb3NpdGlvbnMgPSBzY2FuUG9zaXRpb25zKHJlbmRlcmVkLCB0aGlzLnNlYXJjaEhpZ2hsaWdodFF1ZXJ5KVxuICAgIGxvZ0ZvckRlYnVnZ2luZyhcbiAgICAgIGBzY2FuRWxlbWVudFN1YnRyZWU6IHE9JyR7dGhpcy5zZWFyY2hIaWdobGlnaHRRdWVyeX0nIGAgK1xuICAgICAgICBgZWw9JHt3aWR0aH14JHtoZWlnaHR9QCgke2VsTGVmdH0sJHtlbFRvcH0pIG49JHtwb3NpdGlvbnMubGVuZ3RofSBgICtcbiAgICAgICAgYFske3Bvc2l0aW9uc1xuICAgICAgICAgIC5zbGljZSgwLCAxMClcbiAgICAgICAgICAubWFwKHAgPT4gYCR7cC5yb3d9OiR7cC5jb2x9YClcbiAgICAgICAgICAuam9pbignLCcpfWAgK1xuICAgICAgICBgJHtwb3NpdGlvbnMubGVuZ3RoID4gMTAgPyAnLOKApicgOiAnJ31dYCxcbiAgICApXG4gICAgcmV0dXJuIHBvc2l0aW9uc1xuICB9XG5cbiAgLyoqIFNldCB0aGUgcG9zaXRpb24tYmFzZWQgaGlnaGxpZ2h0IHN0YXRlLiBFdmVyeSBmcmFtZSwgd3JpdGVzIENVUlJFTlRcbiAgICogIHN0eWxlIGF0IHBvc2l0aW9uc1tjdXJyZW50SWR4XSArIHJvd09mZnNldC4gbnVsbCBjbGVhcnMuIFRoZSBzY2FuLVxuICAgKiAgaGlnaGxpZ2h0IChpbnZlcnNlIG9uIGFsbCBtYXRjaGVzKSBzdGlsbCBydW5zIOKAlCB0aGlzIG92ZXJsYXlzIHllbGxvd1xuICAgKiAgb24gdG9wLiByb3dPZmZzZXQgY2hhbmdlcyBhcyB0aGUgdXNlciBzY3JvbGxzICg9IG1lc3NhZ2UncyBjdXJyZW50XG4gICAqICBzY3JlZW4tdG9wKTsgcG9zaXRpb25zIHN0YXkgc3RhYmxlIChtZXNzYWdlLXJlbGF0aXZlKS4gKi9cbiAgc2V0U2VhcmNoUG9zaXRpb25zKFxuICAgIHN0YXRlOiB7XG4gICAgICBwb3NpdGlvbnM6IE1hdGNoUG9zaXRpb25bXVxuICAgICAgcm93T2Zmc2V0OiBudW1iZXJcbiAgICAgIGN1cnJlbnRJZHg6IG51bWJlclxuICAgIH0gfCBudWxsLFxuICApOiB2b2lkIHtcbiAgICB0aGlzLnNlYXJjaFBvc2l0aW9ucyA9IHN0YXRlXG4gICAgdGhpcy5zY2hlZHVsZVJlbmRlcigpXG4gIH1cblxuICAvKipcbiAgICogU2V0IHRoZSBzZWxlY3Rpb24gaGlnaGxpZ2h0IGJhY2tncm91bmQgY29sb3IuIFJlcGxhY2VzIHRoZSBwZXItY2VsbFxuICAgKiBTR1ItNyBpbnZlcnNlIHdpdGggYSBzb2xpZCB0aGVtZS1hd2FyZSBiZyAobWF0Y2hlcyBuYXRpdmUgdGVybWluYWxcbiAgICogc2VsZWN0aW9uKS4gQWNjZXB0cyB0aGUgc2FtZSBjb2xvciBmb3JtYXRzIGFzIFRleHQgYmFja2dyb3VuZENvbG9yXG4gICAqIChyZ2IoKSwgYW5zaTpuYW1lLCAjaGV4LCBhbnNpMjU2KCkpIOKAlCBjb2xvcml6ZSgpIHJvdXRlcyB0aHJvdWdoXG4gICAqIGNoYWxrIHNvIHRoZSB0bXV4L3h0ZXJtLmpzIGxldmVsIGNsYW1wcyBpbiBjb2xvcml6ZS50cyBhcHBseSBhbmRcbiAgICogdGhlIGVtaXR0ZWQgU0dSIGlzIGNvcnJlY3QgZm9yIHRoZSBjdXJyZW50IHRlcm1pbmFsLlxuICAgKlxuICAgKiBDYWxsZWQgYnkgUmVhY3QtbGFuZCBvbmNlIHRoZW1lIGlzIGtub3duIChTY3JvbGxLZXliaW5kaW5nSGFuZGxlcidzXG4gICAqIHVzZUVmZmVjdCB3YXRjaGluZyB1c2VUaGVtZSkuIEJlZm9yZSB0aGF0IGNhbGwsIHdpdGhTZWxlY3Rpb25CZ1xuICAgKiBmYWxscyBiYWNrIHRvIHdpdGhJbnZlcnNlIHNvIHNlbGVjdGlvbiBzdGlsbCByZW5kZXJzIG9uIHRoZSBmaXJzdFxuICAgKiBmcmFtZTsgdGhlIGVmZmVjdCBmaXJlcyBiZWZvcmUgYW55IG1vdXNlIGlucHV0IHNvIHRoZSBmYWxsYmFjayBpc1xuICAgKiB1bm9ic2VydmFibGUgaW4gcHJhY3RpY2UuXG4gICAqL1xuICBzZXRTZWxlY3Rpb25CZ0NvbG9yKGNvbG9yOiBzdHJpbmcpOiB2b2lkIHtcbiAgICAvLyBXcmFwIGEgTlVMIG1hcmtlciwgdGhlbiBzcGxpdCBvbiBpdCB0byBleHRyYWN0IHRoZSBvcGVuL2Nsb3NlIFNHUi5cbiAgICAvLyBjb2xvcml6ZSByZXR1cm5zIHRoZSBpbnB1dCB1bmNoYW5nZWQgaWYgdGhlIGNvbG9yIHN0cmluZyBpcyBiYWQg4oCUXG4gICAgLy8gbm8gTlVMLXNwbGl0IHRoZW4sIHNvIGZhbGwgdGhyb3VnaCB0byBudWxsIChpbnZlcnNlIGZhbGxiYWNrKS5cbiAgICBjb25zdCB3cmFwcGVkID0gY29sb3JpemUoJ1xcMCcsIGNvbG9yLCAnYmFja2dyb3VuZCcpXG4gICAgY29uc3QgbnVsID0gd3JhcHBlZC5pbmRleE9mKCdcXDAnKVxuICAgIGlmIChudWwgPD0gMCB8fCBudWwgPT09IHdyYXBwZWQubGVuZ3RoIC0gMSkge1xuICAgICAgdGhpcy5zdHlsZVBvb2wuc2V0U2VsZWN0aW9uQmcobnVsbClcbiAgICAgIHJldHVyblxuICAgIH1cbiAgICB0aGlzLnN0eWxlUG9vbC5zZXRTZWxlY3Rpb25CZyh7XG4gICAgICB0eXBlOiAnYW5zaScsXG4gICAgICBjb2RlOiB3cmFwcGVkLnNsaWNlKDAsIG51bCksXG4gICAgICBlbmRDb2RlOiB3cmFwcGVkLnNsaWNlKG51bCArIDEpLCAvLyBhbHdheXMgXFx4MWJbNDltIGZvciBiZ1xuICAgIH0pXG4gICAgLy8gTm8gc2NoZWR1bGVSZW5kZXI6IHRoaXMgaXMgY2FsbGVkIGZyb20gYSBSZWFjdCBlZmZlY3QgdGhhdCBhbHJlYWR5XG4gICAgLy8gcnVucyBpbnNpZGUgdGhlIHJlbmRlciBjeWNsZSwgYW5kIHRoZSBiZyBvbmx5IG1hdHRlcnMgb25jZSBhXG4gICAgLy8gc2VsZWN0aW9uIGV4aXN0cyAod2hpY2ggaXRzZWxmIHRyaWdnZXJzIGEgZnVsbC1kYW1hZ2UgZnJhbWUpLlxuICB9XG5cbiAgLyoqXG4gICAqIENhcHR1cmUgdGV4dCBmcm9tIHJvd3MgYWJvdXQgdG8gc2Nyb2xsIG91dCBvZiB0aGUgdmlld3BvcnQgZHVyaW5nXG4gICAqIGRyYWctdG8tc2Nyb2xsLiBNdXN0IGJlIGNhbGxlZCBCRUZPUkUgdGhlIFNjcm9sbEJveCBzY3JvbGxzIHNvIHRoZVxuICAgKiBzY3JlZW4gYnVmZmVyIHN0aWxsIGhvbGRzIHRoZSBvdXRnb2luZyBjb250ZW50LiBBY2N1bXVsYXRlZCBpbnRvXG4gICAqIHRoZSBzZWxlY3Rpb24gc3RhdGUgYW5kIGpvaW5lZCBiYWNrIGluIGJ5IGdldFNlbGVjdGVkVGV4dC5cbiAgICovXG4gIGNhcHR1cmVTY3JvbGxlZFJvd3MoXG4gICAgZmlyc3RSb3c6IG51bWJlcixcbiAgICBsYXN0Um93OiBudW1iZXIsXG4gICAgc2lkZTogJ2Fib3ZlJyB8ICdiZWxvdycsXG4gICk6IHZvaWQge1xuICAgIGNhcHR1cmVTY3JvbGxlZFJvd3MoXG4gICAgICB0aGlzLnNlbGVjdGlvbixcbiAgICAgIHRoaXMuZnJvbnRGcmFtZS5zY3JlZW4sXG4gICAgICBmaXJzdFJvdyxcbiAgICAgIGxhc3RSb3csXG4gICAgICBzaWRlLFxuICAgIClcbiAgfVxuXG4gIC8qKlxuICAgKiBTaGlmdCBhbmNob3IgQU5EIGZvY3VzIGJ5IGRSb3csIGNsYW1wZWQgdG8gW21pblJvdywgbWF4Um93XS4gVXNlZCBieVxuICAgKiBrZXlib2FyZCBzY3JvbGwgaGFuZGxlcnMgKFBnVXAvUGdEbiBldGMuKSBzbyB0aGUgaGlnaGxpZ2h0IHRyYWNrcyB0aGVcbiAgICogY29udGVudCBpbnN0ZWFkIG9mIGRpc2FwcGVhcmluZy4gVW5saWtlIHNoaWZ0QW5jaG9yIChkcmFnLXRvLXNjcm9sbCksXG4gICAqIHRoaXMgbW92ZXMgQk9USCBlbmRwb2ludHMg4oCUIHRoZSB1c2VyIGlzbid0IGhvbGRpbmcgdGhlIG1vdXNlIGF0IG9uZVxuICAgKiBlZGdlLiBTdXBwbGllcyBzY3JlZW4ud2lkdGggZm9yIHRoZSBjb2wtcmVzZXQtb24tY2xhbXAgYm91bmRhcnkuXG4gICAqL1xuICBzaGlmdFNlbGVjdGlvbkZvclNjcm9sbChkUm93OiBudW1iZXIsIG1pblJvdzogbnVtYmVyLCBtYXhSb3c6IG51bWJlcik6IHZvaWQge1xuICAgIGNvbnN0IGhhZFNlbCA9IGhhc1NlbGVjdGlvbih0aGlzLnNlbGVjdGlvbilcbiAgICBzaGlmdFNlbGVjdGlvbihcbiAgICAgIHRoaXMuc2VsZWN0aW9uLFxuICAgICAgZFJvdyxcbiAgICAgIG1pblJvdyxcbiAgICAgIG1heFJvdyxcbiAgICAgIHRoaXMuZnJvbnRGcmFtZS5zY3JlZW4ud2lkdGgsXG4gICAgKVxuICAgIC8vIHNoaWZ0U2VsZWN0aW9uIGNsZWFycyB3aGVuIGJvdGggZW5kcG9pbnRzIG92ZXJzaG9vdCB0aGUgc2FtZSBlZGdlXG4gICAgLy8gKEhvbWUvZy9FbmQvRyBwYWdlLWp1bXAgcGFzdCB0aGUgc2VsZWN0aW9uKS4gTm90aWZ5IHN1YnNjcmliZXJzIHNvXG4gICAgLy8gdXNlSGFzU2VsZWN0aW9uIHVwZGF0ZXMuIFNhZmUgdG8gY2FsbCBub3RpZnlTZWxlY3Rpb25DaGFuZ2UgaGVyZSDigJRcbiAgICAvLyB0aGlzIHJ1bnMgZnJvbSBrZXlib2FyZCBoYW5kbGVycywgbm90IGluc2lkZSBvblJlbmRlcigpLlxuICAgIGlmIChoYWRTZWwgJiYgIWhhc1NlbGVjdGlvbih0aGlzLnNlbGVjdGlvbikpIHtcbiAgICAgIHRoaXMubm90aWZ5U2VsZWN0aW9uQ2hhbmdlKClcbiAgICB9XG4gIH1cblxuICAvKipcbiAgICogS2V5Ym9hcmQgc2VsZWN0aW9uIGV4dGVuc2lvbiAoc2hpZnQrYXJyb3cvaG9tZS9lbmQpLiBNb3ZlcyBmb2N1cztcbiAgICogYW5jaG9yIHN0YXlzIGZpeGVkIHNvIHRoZSBoaWdobGlnaHQgZ3Jvd3Mgb3Igc2hyaW5rcyByZWxhdGl2ZSB0byBpdC5cbiAgICogTGVmdC9yaWdodCB3cmFwIGFjcm9zcyByb3cgYm91bmRhcmllcyDigJQgbmF0aXZlIG1hY09TIHRleHQtZWRpdFxuICAgKiBiZWhhdmlvcjogc2hpZnQrbGVmdCBhdCBjb2wgMCB3cmFwcyB0byBlbmQgb2YgdGhlIHByZXZpb3VzIHJvdy5cbiAgICogVXAvZG93biBjbGFtcCBhdCB2aWV3cG9ydCBlZGdlcyAobm8gc2Nyb2xsLXRvLWV4dGVuZCB5ZXQpLiBEcm9wcyB0b1xuICAgKiBjaGFyIG1vZGUuIE5vLW9wIG91dHNpZGUgYWx0LXNjcmVlbiBvciB3aXRob3V0IGFuIGFjdGl2ZSBzZWxlY3Rpb24uXG4gICAqL1xuICBtb3ZlU2VsZWN0aW9uRm9jdXMobW92ZTogRm9jdXNNb3ZlKTogdm9pZCB7XG4gICAgaWYgKCF0aGlzLmFsdFNjcmVlbkFjdGl2ZSkgcmV0dXJuXG4gICAgY29uc3QgeyBmb2N1cyB9ID0gdGhpcy5zZWxlY3Rpb25cbiAgICBpZiAoIWZvY3VzKSByZXR1cm5cbiAgICBjb25zdCB7IHdpZHRoLCBoZWlnaHQgfSA9IHRoaXMuZnJvbnRGcmFtZS5zY3JlZW5cbiAgICBjb25zdCBtYXhDb2wgPSB3aWR0aCAtIDFcbiAgICBjb25zdCBtYXhSb3cgPSBoZWlnaHQgLSAxXG4gICAgbGV0IHsgY29sLCByb3cgfSA9IGZvY3VzXG4gICAgc3dpdGNoIChtb3ZlKSB7XG4gICAgICBjYXNlICdsZWZ0JzpcbiAgICAgICAgaWYgKGNvbCA+IDApIGNvbC0tXG4gICAgICAgIGVsc2UgaWYgKHJvdyA+IDApIHtcbiAgICAgICAgICBjb2wgPSBtYXhDb2xcbiAgICAgICAgICByb3ctLVxuICAgICAgICB9XG4gICAgICAgIGJyZWFrXG4gICAgICBjYXNlICdyaWdodCc6XG4gICAgICAgIGlmIChjb2wgPCBtYXhDb2wpIGNvbCsrXG4gICAgICAgIGVsc2UgaWYgKHJvdyA8IG1heFJvdykge1xuICAgICAgICAgIGNvbCA9IDBcbiAgICAgICAgICByb3crK1xuICAgICAgICB9XG4gICAgICAgIGJyZWFrXG4gICAgICBjYXNlICd1cCc6XG4gICAgICAgIGlmIChyb3cgPiAwKSByb3ctLVxuICAgICAgICBicmVha1xuICAgICAgY2FzZSAnZG93bic6XG4gICAgICAgIGlmIChyb3cgPCBtYXhSb3cpIHJvdysrXG4gICAgICAgIGJyZWFrXG4gICAgICBjYXNlICdsaW5lU3RhcnQnOlxuICAgICAgICBjb2wgPSAwXG4gICAgICAgIGJyZWFrXG4gICAgICBjYXNlICdsaW5lRW5kJzpcbiAgICAgICAgY29sID0gbWF4Q29sXG4gICAgICAgIGJyZWFrXG4gICAgfVxuICAgIGlmIChjb2wgPT09IGZvY3VzLmNvbCAmJiByb3cgPT09IGZvY3VzLnJvdykgcmV0dXJuXG4gICAgbW92ZUZvY3VzKHRoaXMuc2VsZWN0aW9uLCBjb2wsIHJvdylcbiAgICB0aGlzLm5vdGlmeVNlbGVjdGlvbkNoYW5nZSgpXG4gIH1cblxuICAvKiogV2hldGhlciB0aGVyZSBpcyBhbiBhY3RpdmUgdGV4dCBzZWxlY3Rpb24uICovXG4gIGhhc1RleHRTZWxlY3Rpb24oKTogYm9vbGVhbiB7XG4gICAgcmV0dXJuIGhhc1NlbGVjdGlvbih0aGlzLnNlbGVjdGlvbilcbiAgfVxuXG4gIC8qKlxuICAgKiBTdWJzY3JpYmUgdG8gc2VsZWN0aW9uIHN0YXRlIGNoYW5nZXMuIEZpcmVzIHdoZW5ldmVyIHRoZSBzZWxlY3Rpb25cbiAgICogaXMgc3RhcnRlZCwgdXBkYXRlZCwgY2xlYXJlZCwgb3IgY29waWVkLiBSZXR1cm5zIGFuIHVuc3Vic2NyaWJlIGZuLlxuICAgKi9cbiAgc3Vic2NyaWJlVG9TZWxlY3Rpb25DaGFuZ2UoY2I6ICgpID0+IHZvaWQpOiAoKSA9PiB2b2lkIHtcbiAgICB0aGlzLnNlbGVjdGlvbkxpc3RlbmVycy5hZGQoY2IpXG4gICAgcmV0dXJuICgpID0+IHRoaXMuc2VsZWN0aW9uTGlzdGVuZXJzLmRlbGV0ZShjYilcbiAgfVxuXG4gIHByaXZhdGUgbm90aWZ5U2VsZWN0aW9uQ2hhbmdlKCk6IHZvaWQge1xuICAgIHRoaXMub25SZW5kZXIoKVxuICAgIGZvciAoY29uc3QgY2Igb2YgdGhpcy5zZWxlY3Rpb25MaXN0ZW5lcnMpIGNiKClcbiAgfVxuXG4gIC8qKlxuICAgKiBIaXQtdGVzdCB0aGUgcmVuZGVyZWQgRE9NIHRyZWUgYXQgKGNvbCwgcm93KSBhbmQgYnViYmxlIGEgQ2xpY2tFdmVudFxuICAgKiBmcm9tIHRoZSBkZWVwZXN0IGhpdCBub2RlIHVwIHRocm91Z2ggYW5jZXN0b3JzIHdpdGggb25DbGljayBoYW5kbGVycy5cbiAgICogUmV0dXJucyB0cnVlIGlmIGEgRE9NIGhhbmRsZXIgY29uc3VtZWQgdGhlIGNsaWNrLiBHYXRlZCBvblxuICAgKiBhbHRTY3JlZW5BY3RpdmUg4oCUIGNsaWNrcyBvbmx5IG1ha2Ugc2Vuc2Ugd2l0aCBhIGZpeGVkIHZpZXdwb3J0IHdoZXJlXG4gICAqIG5vZGVDYWNoZSByZWN0cyBtYXAgMToxIHRvIHRlcm1pbmFsIGNlbGxzIChubyBzY3JvbGxiYWNrIG9mZnNldCkuXG4gICAqL1xuICBkaXNwYXRjaENsaWNrKGNvbDogbnVtYmVyLCByb3c6IG51bWJlcik6IGJvb2xlYW4ge1xuICAgIGlmICghdGhpcy5hbHRTY3JlZW5BY3RpdmUpIHJldHVybiBmYWxzZVxuICAgIGNvbnN0IGJsYW5rID0gaXNFbXB0eUNlbGxBdCh0aGlzLmZyb250RnJhbWUuc2NyZWVuLCBjb2wsIHJvdylcbiAgICByZXR1cm4gZGlzcGF0Y2hDbGljayh0aGlzLnJvb3ROb2RlLCBjb2wsIHJvdywgYmxhbmspXG4gIH1cblxuICBkaXNwYXRjaEhvdmVyKGNvbDogbnVtYmVyLCByb3c6IG51bWJlcik6IHZvaWQge1xuICAgIGlmICghdGhpcy5hbHRTY3JlZW5BY3RpdmUpIHJldHVyblxuICAgIGRpc3BhdGNoSG92ZXIodGhpcy5yb290Tm9kZSwgY29sLCByb3csIHRoaXMuaG92ZXJlZE5vZGVzKVxuICB9XG5cbiAgZGlzcGF0Y2hLZXlib2FyZEV2ZW50KHBhcnNlZEtleTogUGFyc2VkS2V5KTogdm9pZCB7XG4gICAgY29uc3QgdGFyZ2V0ID0gdGhpcy5mb2N1c01hbmFnZXIuYWN0aXZlRWxlbWVudCA/PyB0aGlzLnJvb3ROb2RlXG4gICAgY29uc3QgZXZlbnQgPSBuZXcgS2V5Ym9hcmRFdmVudChwYXJzZWRLZXkpXG4gICAgZGlzcGF0Y2hlci5kaXNwYXRjaERpc2NyZXRlKHRhcmdldCwgZXZlbnQpXG5cbiAgICAvLyBUYWIgY3ljbGluZyBpcyB0aGUgZGVmYXVsdCBhY3Rpb24g4oCUIG9ubHkgZmlyZXMgaWYgbm8gaGFuZGxlclxuICAgIC8vIGNhbGxlZCBwcmV2ZW50RGVmYXVsdCgpLiBNaXJyb3JzIGJyb3dzZXIgYmVoYXZpb3IuXG4gICAgaWYgKFxuICAgICAgIWV2ZW50LmRlZmF1bHRQcmV2ZW50ZWQgJiZcbiAgICAgIHBhcnNlZEtleS5uYW1lID09PSAndGFiJyAmJlxuICAgICAgIXBhcnNlZEtleS5jdHJsICYmXG4gICAgICAhcGFyc2VkS2V5Lm1ldGFcbiAgICApIHtcbiAgICAgIGlmIChwYXJzZWRLZXkuc2hpZnQpIHtcbiAgICAgICAgdGhpcy5mb2N1c01hbmFnZXIuZm9jdXNQcmV2aW91cyh0aGlzLnJvb3ROb2RlKVxuICAgICAgfSBlbHNlIHtcbiAgICAgICAgdGhpcy5mb2N1c01hbmFnZXIuZm9jdXNOZXh0KHRoaXMucm9vdE5vZGUpXG4gICAgICB9XG4gICAgfVxuICB9XG4gIC8qKlxuICAgKiBMb29rIHVwIHRoZSBVUkwgYXQgKGNvbCwgcm93KSBpbiB0aGUgY3VycmVudCBmcm9udCBmcmFtZS4gQ2hlY2tzIGZvclxuICAgKiBhbiBPU0MgOCBoeXBlcmxpbmsgZmlyc3QsIHRoZW4gZmFsbHMgYmFjayB0byBzY2FubmluZyB0aGUgcm93IGZvciBhXG4gICAqIHBsYWluLXRleHQgVVJMIChtb3VzZSB0cmFja2luZyBpbnRlcmNlcHRzIHRoZSB0ZXJtaW5hbCdzIG5hdGl2ZVxuICAgKiBDbWQrQ2xpY2sgVVJMIGRldGVjdGlvbiwgc28gd2UgcmVwbGljYXRlIGl0KS4gVGhpcyBpcyBhIHB1cmUgbG9va3VwXG4gICAqIHdpdGggbm8gc2lkZSBlZmZlY3RzIOKAlCBjYWxsIGl0IHN5bmNocm9ub3VzbHkgYXQgY2xpY2sgdGltZSBzbyB0aGVcbiAgICogcmVzdWx0IHJlZmxlY3RzIHRoZSBzY3JlZW4gdGhlIHVzZXIgYWN0dWFsbHkgY2xpY2tlZCBvbiwgdGhlbiBkZWZlclxuICAgKiB0aGUgYnJvd3Nlci1vcGVuIGFjdGlvbiB2aWEgYSB0aW1lci5cbiAgICovXG4gIGdldEh5cGVybGlua0F0KGNvbDogbnVtYmVyLCByb3c6IG51bWJlcik6IHN0cmluZyB8IHVuZGVmaW5lZCB7XG4gICAgaWYgKCF0aGlzLmFsdFNjcmVlbkFjdGl2ZSkgcmV0dXJuIHVuZGVmaW5lZFxuICAgIGNvbnN0IHNjcmVlbiA9IHRoaXMuZnJvbnRGcmFtZS5zY3JlZW5cbiAgICBjb25zdCBjZWxsID0gY2VsbEF0KHNjcmVlbiwgY29sLCByb3cpXG4gICAgbGV0IHVybCA9IGNlbGw/Lmh5cGVybGlua1xuICAgIC8vIFNwYWNlclRhaWwgY2VsbHMgKHJpZ2h0IGhhbGYgb2Ygd2lkZS9DSksvZW1vamkgY2hhcnMpIHN0b3JlIHRoZVxuICAgIC8vIGh5cGVybGluayBvbiB0aGUgaGVhZCBjZWxsIGF0IGNvbC0xLlxuICAgIGlmICghdXJsICYmIGNlbGw/LndpZHRoID09PSBDZWxsV2lkdGguU3BhY2VyVGFpbCAmJiBjb2wgPiAwKSB7XG4gICAgICB1cmwgPSBjZWxsQXQoc2NyZWVuLCBjb2wgLSAxLCByb3cpPy5oeXBlcmxpbmtcbiAgICB9XG4gICAgcmV0dXJuIHVybCA/PyBmaW5kUGxhaW5UZXh0VXJsQXQoc2NyZWVuLCBjb2wsIHJvdylcbiAgfVxuXG4gIC8qKlxuICAgKiBPcHRpb25hbCBjYWxsYmFjayBmaXJlZCB3aGVuIGNsaWNraW5nIGFuIE9TQyA4IGh5cGVybGluayBpbiBmdWxsc2NyZWVuXG4gICAqIG1vZGUuIFNldCBieSBGdWxsc2NyZWVuTGF5b3V0IHZpYSB1c2VMYXlvdXRFZmZlY3QuXG4gICAqL1xuICBvbkh5cGVybGlua0NsaWNrOiAoKHVybDogc3RyaW5nKSA9PiB2b2lkKSB8IHVuZGVmaW5lZFxuXG4gIC8qKlxuICAgKiBTdGFibGUgcHJvdG90eXBlIHdyYXBwZXIgZm9yIG9uSHlwZXJsaW5rQ2xpY2suIFBhc3NlZCB0byA8QXBwPiBhc1xuICAgKiBvbk9wZW5IeXBlcmxpbmsgc28gdGhlIHByb3AgaXMgYSBib3VuZCBtZXRob2QgKGF1dG9CaW5kJ2QpIHRoYXQgcmVhZHNcbiAgICogdGhlIG11dGFibGUgZmllbGQgYXQgY2FsbCB0aW1lIOKAlCBub3QgdGhlIHVuZGVmaW5lZC1hdC1yZW5kZXIgdmFsdWUuXG4gICAqL1xuICBvcGVuSHlwZXJsaW5rKHVybDogc3RyaW5nKTogdm9pZCB7XG4gICAgdGhpcy5vbkh5cGVybGlua0NsaWNrPy4odXJsKVxuICB9XG5cbiAgLyoqXG4gICAqIEhhbmRsZSBhIGRvdWJsZS0gb3IgdHJpcGxlLWNsaWNrIGF0IChjb2wsIHJvdyk6IHNlbGVjdCB0aGUgd29yZCBvclxuICAgKiBsaW5lIHVuZGVyIHRoZSBjdXJzb3IgYnkgcmVhZGluZyB0aGUgY3VycmVudCBzY3JlZW4gYnVmZmVyLiBDYWxsZWQgb25cbiAgICogUFJFU1MgKG5vdCByZWxlYXNlKSBzbyB0aGUgaGlnaGxpZ2h0IGFwcGVhcnMgaW1tZWRpYXRlbHkgYW5kIGRyYWcgY2FuXG4gICAqIGV4dGVuZCB0aGUgc2VsZWN0aW9uIHdvcmQtYnktd29yZCAvIGxpbmUtYnktbGluZS4gRmFsbHMgYmFjayB0b1xuICAgKiBjaGFyLW1vZGUgc3RhcnRTZWxlY3Rpb24gaWYgdGhlIGNsaWNrIGxhbmRzIG9uIGEgbm9TZWxlY3QgY2VsbC5cbiAgICovXG4gIGhhbmRsZU11bHRpQ2xpY2soY29sOiBudW1iZXIsIHJvdzogbnVtYmVyLCBjb3VudDogMiB8IDMpOiB2b2lkIHtcbiAgICBpZiAoIXRoaXMuYWx0U2NyZWVuQWN0aXZlKSByZXR1cm5cbiAgICBjb25zdCBzY3JlZW4gPSB0aGlzLmZyb250RnJhbWUuc2NyZWVuXG4gICAgLy8gc2VsZWN0V29yZEF0L3NlbGVjdExpbmVBdCBuby1vcCBvbiBub1NlbGVjdC9vdXQtb2YtYm91bmRzLiBTZWVkIHdpdGhcbiAgICAvLyBhIGNoYXItbW9kZSBzZWxlY3Rpb24gc28gdGhlIHByZXNzIHN0aWxsIHN0YXJ0cyBhIGRyYWcgZXZlbiBpZiB0aGVcbiAgICAvLyB3b3JkL2xpbmUgc2NhbiBmaW5kcyBub3RoaW5nIHNlbGVjdGFibGUuXG4gICAgc3RhcnRTZWxlY3Rpb24odGhpcy5zZWxlY3Rpb24sIGNvbCwgcm93KVxuICAgIGlmIChjb3VudCA9PT0gMikgc2VsZWN0V29yZEF0KHRoaXMuc2VsZWN0aW9uLCBzY3JlZW4sIGNvbCwgcm93KVxuICAgIGVsc2Ugc2VsZWN0TGluZUF0KHRoaXMuc2VsZWN0aW9uLCBzY3JlZW4sIHJvdylcbiAgICAvLyBFbnN1cmUgaGFzU2VsZWN0aW9uIGlzIHRydWUgc28gcmVsZWFzZSBkb2Vzbid0IHJlLWRpc3BhdGNoIG9uQ2xpY2tBdC5cbiAgICAvLyBzZWxlY3RXb3JkQXQgbm8tb3BzIG9uIG5vU2VsZWN0OyBzZWxlY3RMaW5lQXQgbm8tb3BzIG91dC1vZi1ib3VuZHMuXG4gICAgaWYgKCF0aGlzLnNlbGVjdGlvbi5mb2N1cykgdGhpcy5zZWxlY3Rpb24uZm9jdXMgPSB0aGlzLnNlbGVjdGlvbi5hbmNob3JcbiAgICB0aGlzLm5vdGlmeVNlbGVjdGlvbkNoYW5nZSgpXG4gIH1cblxuICAvKipcbiAgICogSGFuZGxlIGEgZHJhZy1tb3Rpb24gYXQgKGNvbCwgcm93KS4gSW4gY2hhciBtb2RlIHVwZGF0ZXMgZm9jdXMgdG8gdGhlXG4gICAqIGV4YWN0IGNlbGwuIEluIHdvcmQvbGluZSBtb2RlIHNuYXBzIHRvIHdvcmQvbGluZSBib3VuZGFyaWVzIHNvIHRoZVxuICAgKiBzZWxlY3Rpb24gZXh0ZW5kcyBieSB3b3JkL2xpbmUgbGlrZSBuYXRpdmUgbWFjT1MuIEdhdGVkIG9uXG4gICAqIGFsdFNjcmVlbkFjdGl2ZSBmb3IgdGhlIHNhbWUgcmVhc29uIGFzIGRpc3BhdGNoQ2xpY2suXG4gICAqL1xuICBoYW5kbGVTZWxlY3Rpb25EcmFnKGNvbDogbnVtYmVyLCByb3c6IG51bWJlcik6IHZvaWQge1xuICAgIGlmICghdGhpcy5hbHRTY3JlZW5BY3RpdmUpIHJldHVyblxuICAgIGNvbnN0IHNlbCA9IHRoaXMuc2VsZWN0aW9uXG4gICAgaWYgKHNlbC5hbmNob3JTcGFuKSB7XG4gICAgICBleHRlbmRTZWxlY3Rpb24oc2VsLCB0aGlzLmZyb250RnJhbWUuc2NyZWVuLCBjb2wsIHJvdylcbiAgICB9IGVsc2Uge1xuICAgICAgdXBkYXRlU2VsZWN0aW9uKHNlbCwgY29sLCByb3cpXG4gICAgfVxuICAgIHRoaXMubm90aWZ5U2VsZWN0aW9uQ2hhbmdlKClcbiAgfVxuXG4gIC8vIE1ldGhvZHMgdG8gcHJvcGVybHkgc3VzcGVuZCBzdGRpbiBmb3IgZXh0ZXJuYWwgZWRpdG9yIHVzYWdlXG4gIC8vIFRoaXMgaXMgbmVlZGVkIHRvIHByZXZlbnQgSW5rIGZyb20gc3dhbGxvd2luZyBrZXlzdHJva2VzIHdoZW4gYW4gZXh0ZXJuYWwgZWRpdG9yIGlzIGFjdGl2ZVxuICBwcml2YXRlIHN0ZGluTGlzdGVuZXJzOiBBcnJheTx7XG4gICAgZXZlbnQ6IHN0cmluZ1xuICAgIGxpc3RlbmVyOiAoLi4uYXJnczogdW5rbm93bltdKSA9PiB2b2lkXG4gIH0+ID0gW11cbiAgcHJpdmF0ZSB3YXNSYXdNb2RlID0gZmFsc2VcblxuICBzdXNwZW5kU3RkaW4oKTogdm9pZCB7XG4gICAgY29uc3Qgc3RkaW4gPSB0aGlzLm9wdGlvbnMuc3RkaW5cbiAgICBpZiAoIXN0ZGluLmlzVFRZKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG5cbiAgICAvLyBTdG9yZSBhbmQgcmVtb3ZlIGFsbCAncmVhZGFibGUnIGV2ZW50IGxpc3RlbmVycyB0ZW1wb3JhcmlseVxuICAgIC8vIFRoaXMgcHJldmVudHMgSW5rIGZyb20gY29uc3VtaW5nIHN0ZGluIHdoaWxlIHRoZSBlZGl0b3IgaXMgYWN0aXZlXG4gICAgY29uc3QgcmVhZGFibGVMaXN0ZW5lcnMgPSBzdGRpbi5saXN0ZW5lcnMoJ3JlYWRhYmxlJylcbiAgICBsb2dGb3JEZWJ1Z2dpbmcoXG4gICAgICBgW3N0ZGluXSBzdXNwZW5kU3RkaW46IHJlbW92aW5nICR7cmVhZGFibGVMaXN0ZW5lcnMubGVuZ3RofSByZWFkYWJsZSBsaXN0ZW5lcihzKSwgd2FzUmF3TW9kZT0keyhzdGRpbiBhcyBOb2RlSlMuUmVhZFN0cmVhbSAmIHsgaXNSYXc/OiBib29sZWFuIH0pLmlzUmF3ID8/IGZhbHNlfWAsXG4gICAgKVxuICAgIHJlYWRhYmxlTGlzdGVuZXJzLmZvckVhY2gobGlzdGVuZXIgPT4ge1xuICAgICAgdGhpcy5zdGRpbkxpc3RlbmVycy5wdXNoKHtcbiAgICAgICAgZXZlbnQ6ICdyZWFkYWJsZScsXG4gICAgICAgIGxpc3RlbmVyOiBsaXN0ZW5lciBhcyAoLi4uYXJnczogdW5rbm93bltdKSA9PiB2b2lkLFxuICAgICAgfSlcbiAgICAgIHN0ZGluLnJlbW92ZUxpc3RlbmVyKCdyZWFkYWJsZScsIGxpc3RlbmVyIGFzICguLi5hcmdzOiB1bmtub3duW10pID0+IHZvaWQpXG4gICAgfSlcblxuICAgIC8vIElmIHJhdyBtb2RlIGlzIGVuYWJsZWQsIGRpc2FibGUgaXQgdGVtcG9yYXJpbHlcbiAgICBjb25zdCBzdGRpbldpdGhSYXcgPSBzdGRpbiBhcyBOb2RlSlMuUmVhZFN0cmVhbSAmIHtcbiAgICAgIGlzUmF3PzogYm9vbGVhblxuICAgICAgc2V0UmF3TW9kZT86IChtb2RlOiBib29sZWFuKSA9PiB2b2lkXG4gICAgfVxuICAgIGlmIChzdGRpbldpdGhSYXcuaXNSYXcgJiYgc3RkaW5XaXRoUmF3LnNldFJhd01vZGUpIHtcbiAgICAgIHN0ZGluV2l0aFJhdy5zZXRSYXdNb2RlKGZhbHNlKVxuICAgICAgdGhpcy53YXNSYXdNb2RlID0gdHJ1ZVxuICAgIH1cbiAgfVxuXG4gIHJlc3VtZVN0ZGluKCk6IHZvaWQge1xuICAgIGNvbnN0IHN0ZGluID0gdGhpcy5vcHRpb25zLnN0ZGluXG4gICAgaWYgKCFzdGRpbi5pc1RUWSkge1xuICAgICAgcmV0dXJuXG4gICAgfVxuXG4gICAgLy8gUmUtYXR0YWNoIGFsbCB0aGUgc3RvcmVkIGxpc3RlbmVyc1xuICAgIGlmICh0aGlzLnN0ZGluTGlzdGVuZXJzLmxlbmd0aCA9PT0gMCAmJiAhdGhpcy53YXNSYXdNb2RlKSB7XG4gICAgICBsb2dGb3JEZWJ1Z2dpbmcoXG4gICAgICAgICdbc3RkaW5dIHJlc3VtZVN0ZGluOiBjYWxsZWQgd2l0aCBubyBzdG9yZWQgbGlzdGVuZXJzIGFuZCB3YXNSYXdNb2RlPWZhbHNlIChwb3NzaWJsZSBkZXN5bmMpJyxcbiAgICAgICAgeyBsZXZlbDogJ3dhcm4nIH0sXG4gICAgICApXG4gICAgfVxuICAgIGxvZ0ZvckRlYnVnZ2luZyhcbiAgICAgIGBbc3RkaW5dIHJlc3VtZVN0ZGluOiByZS1hdHRhY2hpbmcgJHt0aGlzLnN0ZGluTGlzdGVuZXJzLmxlbmd0aH0gbGlzdGVuZXIocyksIHdhc1Jhd01vZGU9JHt0aGlzLndhc1Jhd01vZGV9YCxcbiAgICApXG4gICAgdGhpcy5zdGRpbkxpc3RlbmVycy5mb3JFYWNoKCh7IGV2ZW50LCBsaXN0ZW5lciB9KSA9PiB7XG4gICAgICBzdGRpbi5hZGRMaXN0ZW5lcihldmVudCwgbGlzdGVuZXIpXG4gICAgfSlcbiAgICB0aGlzLnN0ZGluTGlzdGVuZXJzID0gW11cblxuICAgIC8vIFJlLWVuYWJsZSByYXcgbW9kZSBpZiBpdCB3YXMgZW5hYmxlZCBiZWZvcmVcbiAgICBpZiAodGhpcy53YXNSYXdNb2RlKSB7XG4gICAgICBjb25zdCBzdGRpbldpdGhSYXcgPSBzdGRpbiBhcyBOb2RlSlMuUmVhZFN0cmVhbSAmIHtcbiAgICAgICAgc2V0UmF3TW9kZT86IChtb2RlOiBib29sZWFuKSA9PiB2b2lkXG4gICAgICB9XG4gICAgICBpZiAoc3RkaW5XaXRoUmF3LnNldFJhd01vZGUpIHtcbiAgICAgICAgc3RkaW5XaXRoUmF3LnNldFJhd01vZGUodHJ1ZSlcbiAgICAgIH1cbiAgICAgIHRoaXMud2FzUmF3TW9kZSA9IGZhbHNlXG4gICAgfVxuICB9XG5cbiAgLy8gU3RhYmxlIGlkZW50aXR5IGZvciBUZXJtaW5hbFdyaXRlQ29udGV4dC4gQW4gaW5saW5lIGFycm93IGhlcmUgd291bGRcbiAgLy8gY2hhbmdlIG9uIGV2ZXJ5IHJlbmRlcigpIGNhbGwgKGluaXRpYWwgbW91bnQgKyBlYWNoIHJlc2l6ZSksIHdoaWNoXG4gIC8vIGNhc2NhZGVzIHRocm91Z2ggdXNlQ29udGV4dCDihpIgPEFsdGVybmF0ZVNjcmVlbj4ncyB1c2VMYXlvdXRFZmZlY3QgZGVwXG4gIC8vIGFycmF5IOKGkiBzcHVyaW91cyBleGl0K3JlLWVudGVyIG9mIHRoZSBhbHQgc2NyZWVuIG9uIGV2ZXJ5IFNJR1dJTkNILlxuICBwcml2YXRlIHdyaXRlUmF3KGRhdGE6IHN0cmluZyk6IHZvaWQge1xuICAgIHRoaXMub3B0aW9ucy5zdGRvdXQud3JpdGUoZGF0YSlcbiAgfVxuXG4gIHByaXZhdGUgc2V0Q3Vyc29yRGVjbGFyYXRpb246IEN1cnNvckRlY2xhcmF0aW9uU2V0dGVyID0gKFxuICAgIGRlY2wsXG4gICAgY2xlYXJJZk5vZGUsXG4gICkgPT4ge1xuICAgIGlmIChcbiAgICAgIGRlY2wgPT09IG51bGwgJiZcbiAgICAgIGNsZWFySWZOb2RlICE9PSB1bmRlZmluZWQgJiZcbiAgICAgIHRoaXMuY3Vyc29yRGVjbGFyYXRpb24/Lm5vZGUgIT09IGNsZWFySWZOb2RlXG4gICAgKSB7XG4gICAgICByZXR1cm5cbiAgICB9XG4gICAgdGhpcy5jdXJzb3JEZWNsYXJhdGlvbiA9IGRlY2xcbiAgfVxuXG4gIHJlbmRlcihub2RlOiBSZWFjdE5vZGUpOiB2b2lkIHtcbiAgICB0aGlzLmN1cnJlbnROb2RlID0gbm9kZVxuXG4gICAgY29uc3QgdHJlZSA9IChcbiAgICAgIDxBcHBcbiAgICAgICAgc3RkaW49e3RoaXMub3B0aW9ucy5zdGRpbn1cbiAgICAgICAgc3Rkb3V0PXt0aGlzLm9wdGlvbnMuc3Rkb3V0fVxuICAgICAgICBzdGRlcnI9e3RoaXMub3B0aW9ucy5zdGRlcnJ9XG4gICAgICAgIGV4aXRPbkN0cmxDPXt0aGlzLm9wdGlvbnMuZXhpdE9uQ3RybEN9XG4gICAgICAgIG9uRXhpdD17dGhpcy51bm1vdW50fVxuICAgICAgICB0ZXJtaW5hbENvbHVtbnM9e3RoaXMudGVybWluYWxDb2x1bW5zfVxuICAgICAgICB0ZXJtaW5hbFJvd3M9e3RoaXMudGVybWluYWxSb3dzfVxuICAgICAgICBzZWxlY3Rpb249e3RoaXMuc2VsZWN0aW9ufVxuICAgICAgICBvblNlbGVjdGlvbkNoYW5nZT17dGhpcy5ub3RpZnlTZWxlY3Rpb25DaGFuZ2V9XG4gICAgICAgIG9uQ2xpY2tBdD17dGhpcy5kaXNwYXRjaENsaWNrfVxuICAgICAgICBvbkhvdmVyQXQ9e3RoaXMuZGlzcGF0Y2hIb3Zlcn1cbiAgICAgICAgZ2V0SHlwZXJsaW5rQXQ9e3RoaXMuZ2V0SHlwZXJsaW5rQXR9XG4gICAgICAgIG9uT3Blbkh5cGVybGluaz17dGhpcy5vcGVuSHlwZXJsaW5rfVxuICAgICAgICBvbk11bHRpQ2xpY2s9e3RoaXMuaGFuZGxlTXVsdGlDbGlja31cbiAgICAgICAgb25TZWxlY3Rpb25EcmFnPXt0aGlzLmhhbmRsZVNlbGVjdGlvbkRyYWd9XG4gICAgICAgIG9uU3RkaW5SZXN1bWU9e3RoaXMucmVhc3NlcnRUZXJtaW5hbE1vZGVzfVxuICAgICAgICBvbkN1cnNvckRlY2xhcmF0aW9uPXt0aGlzLnNldEN1cnNvckRlY2xhcmF0aW9ufVxuICAgICAgICBkaXNwYXRjaEtleWJvYXJkRXZlbnQ9e3RoaXMuZGlzcGF0Y2hLZXlib2FyZEV2ZW50fVxuICAgICAgPlxuICAgICAgICA8VGVybWluYWxXcml0ZVByb3ZpZGVyIHZhbHVlPXt0aGlzLndyaXRlUmF3fT5cbiAgICAgICAgICB7bm9kZX1cbiAgICAgICAgPC9UZXJtaW5hbFdyaXRlUHJvdmlkZXI+XG4gICAgICA8L0FwcD5cbiAgICApXG5cbiAgICAvLyBAdHMtZXhwZWN0LWVycm9yIHVwZGF0ZUNvbnRhaW5lclN5bmMgZXhpc3RzIGluIHJlYWN0LXJlY29uY2lsZXIgYnV0IG5vdCBpbiBAdHlwZXMvcmVhY3QtcmVjb25jaWxlclxuICAgIHJlY29uY2lsZXIudXBkYXRlQ29udGFpbmVyU3luYyh0cmVlLCB0aGlzLmNvbnRhaW5lciwgbnVsbCwgbm9vcClcbiAgICAvLyBAdHMtZXhwZWN0LWVycm9yIGZsdXNoU3luY1dvcmsgZXhpc3RzIGluIHJlYWN0LXJlY29uY2lsZXIgYnV0IG5vdCBpbiBAdHlwZXMvcmVhY3QtcmVjb25jaWxlclxuICAgIHJlY29uY2lsZXIuZmx1c2hTeW5jV29yaygpXG4gIH1cblxuICB1bm1vdW50KGVycm9yPzogRXJyb3IgfCBudW1iZXIgfCBudWxsKTogdm9pZCB7XG4gICAgaWYgKHRoaXMuaXNVbm1vdW50ZWQpIHtcbiAgICAgIHJldHVyblxuICAgIH1cblxuICAgIHRoaXMub25SZW5kZXIoKVxuICAgIHRoaXMudW5zdWJzY3JpYmVFeGl0KClcblxuICAgIGlmICh0eXBlb2YgdGhpcy5yZXN0b3JlQ29uc29sZSA9PT0gJ2Z1bmN0aW9uJykge1xuICAgICAgdGhpcy5yZXN0b3JlQ29uc29sZSgpXG4gICAgfVxuICAgIHRoaXMucmVzdG9yZVN0ZGVycj8uKClcblxuICAgIHRoaXMudW5zdWJzY3JpYmVUVFlIYW5kbGVycz8uKClcblxuICAgIC8vIE5vbi1UVFkgZW52aXJvbm1lbnRzIGRvbid0IGhhbmRsZSBlcmFzaW5nIGFuc2kgZXNjYXBlcyB3ZWxsLCBzbyBpdCdzIGJldHRlciB0b1xuICAgIC8vIG9ubHkgcmVuZGVyIGxhc3QgZnJhbWUgb2Ygbm9uLXN0YXRpYyBvdXRwdXRcbiAgICBjb25zdCBkaWZmID0gdGhpcy5sb2cucmVuZGVyUHJldmlvdXNPdXRwdXRfREVQUkVDQVRFRCh0aGlzLmZyb250RnJhbWUpXG4gICAgd3JpdGVEaWZmVG9UZXJtaW5hbCh0aGlzLnRlcm1pbmFsLCBvcHRpbWl6ZShkaWZmKSlcblxuICAgIC8vIENsZWFuIHVwIHRlcm1pbmFsIG1vZGVzIHN5bmNocm9ub3VzbHkgYmVmb3JlIHByb2Nlc3MgZXhpdC5cbiAgICAvLyBSZWFjdCdzIGNvbXBvbmVudFdpbGxVbm1vdW50IHdvbid0IHJ1biBpbiB0aW1lIHdoZW4gcHJvY2Vzcy5leGl0KCkgaXMgY2FsbGVkLFxuICAgIC8vIHNvIHdlIG11c3QgcmVzZXQgdGVybWluYWwgbW9kZXMgaGVyZSB0byBwcmV2ZW50IGVzY2FwZSBzZXF1ZW5jZSBsZWFrYWdlLlxuICAgIC8vIFVzZSB3cml0ZVN5bmMgdG8gc3Rkb3V0IChmZCAxKSB0byBlbnN1cmUgd3JpdGVzIGNvbXBsZXRlIGJlZm9yZSBleGl0LlxuICAgIC8vIFdlIHVuY29uZGl0aW9uYWxseSBzZW5kIGFsbCBkaXNhYmxlIHNlcXVlbmNlcyBiZWNhdXNlIHRlcm1pbmFsIGRldGVjdGlvblxuICAgIC8vIG1heSBub3Qgd29yayBjb3JyZWN0bHkgKGUuZy4sIGluIHRtdXgsIHNjcmVlbikgYW5kIHRoZXNlIGFyZSBuby1vcHMgb25cbiAgICAvLyB0ZXJtaW5hbHMgdGhhdCBkb24ndCBzdXBwb3J0IHRoZW0uXG4gICAgLyogZXNsaW50LWRpc2FibGUgY3VzdG9tLXJ1bGVzL25vLXN5bmMtZnMgLS0gcHJvY2VzcyBleGl0aW5nOyBhc3luYyB3cml0ZXMgd291bGQgYmUgZHJvcHBlZCAqL1xuICAgIGlmICh0aGlzLm9wdGlvbnMuc3Rkb3V0LmlzVFRZKSB7XG4gICAgICBpZiAodGhpcy5hbHRTY3JlZW5BY3RpdmUpIHtcbiAgICAgICAgLy8gPEFsdGVybmF0ZVNjcmVlbj4ncyB1bm1vdW50IGVmZmVjdCB3b24ndCBydW4gZHVyaW5nIHNpZ25hbC1leGl0LlxuICAgICAgICAvLyBFeGl0IGFsdCBzY3JlZW4gRklSU1Qgc28gb3RoZXIgY2xlYW51cCBzZXF1ZW5jZXMgZ28gdG8gdGhlIG1haW4gc2NyZWVuLlxuICAgICAgICB3cml0ZVN5bmMoMSwgRVhJVF9BTFRfU0NSRUVOKVxuICAgICAgfVxuICAgICAgLy8gRGlzYWJsZSBtb3VzZSB0cmFja2luZyDigJQgdW5jb25kaXRpb25hbCBiZWNhdXNlIGFsdFNjcmVlbkFjdGl2ZSBjYW4gYmVcbiAgICAgIC8vIHN0YWxlIGlmIEFsdGVybmF0ZVNjcmVlbidzIHVubW91bnQgKHdoaWNoIGZsaXBzIHRoZSBmbGFnKSByYWNlZCBhXG4gICAgICAvLyBibG9ja2VkIGV2ZW50IGxvb3AgKyBTSUdJTlQuIE5vLW9wIGlmIHRyYWNraW5nIHdhcyBuZXZlciBlbmFibGVkLlxuICAgICAgd3JpdGVTeW5jKDEsIERJU0FCTEVfTU9VU0VfVFJBQ0tJTkcpXG4gICAgICAvLyBEcmFpbiBzdGRpbiBzbyBpbi1mbGlnaHQgbW91c2UgZXZlbnRzIGRvbid0IGxlYWsgdG8gdGhlIHNoZWxsXG4gICAgICB0aGlzLmRyYWluU3RkaW4oKVxuICAgICAgLy8gRGlzYWJsZSBleHRlbmRlZCBrZXkgcmVwb3J0aW5nIChib3RoIGtpdHR5IGFuZCBtb2RpZnlPdGhlcktleXMpXG4gICAgICB3cml0ZVN5bmMoMSwgRElTQUJMRV9NT0RJRllfT1RIRVJfS0VZUylcbiAgICAgIHdyaXRlU3luYygxLCBESVNBQkxFX0tJVFRZX0tFWUJPQVJEKVxuICAgICAgLy8gRGlzYWJsZSBmb2N1cyBldmVudHMgKERFQ1NFVCAxMDA0KVxuICAgICAgd3JpdGVTeW5jKDEsIERGRSlcbiAgICAgIC8vIERpc2FibGUgYnJhY2tldGVkIHBhc3RlIG1vZGVcbiAgICAgIHdyaXRlU3luYygxLCBEQlApXG4gICAgICAvLyBTaG93IGN1cnNvclxuICAgICAgd3JpdGVTeW5jKDEsIFNIT1dfQ1VSU09SKVxuICAgICAgLy8gQ2xlYXIgaVRlcm0yIHByb2dyZXNzIGJhclxuICAgICAgd3JpdGVTeW5jKDEsIENMRUFSX0lURVJNMl9QUk9HUkVTUylcbiAgICAgIC8vIENsZWFyIHRhYiBzdGF0dXMgKE9TQyAyMTMzNykgc28gYSBzdGFsZSBkb3QgZG9lc24ndCBsaW5nZXJcbiAgICAgIGlmIChzdXBwb3J0c1RhYlN0YXR1cygpKVxuICAgICAgICB3cml0ZVN5bmMoMSwgd3JhcEZvck11bHRpcGxleGVyKENMRUFSX1RBQl9TVEFUVVMpKVxuICAgIH1cbiAgICAvKiBlc2xpbnQtZW5hYmxlIGN1c3RvbS1ydWxlcy9uby1zeW5jLWZzICovXG5cbiAgICB0aGlzLmlzVW5tb3VudGVkID0gdHJ1ZVxuXG4gICAgLy8gQ2FuY2VsIGFueSBwZW5kaW5nIHRocm90dGxlZCByZW5kZXJzIHRvIHByZXZlbnQgYWNjZXNzaW5nIGZyZWVkIFlvZ2Egbm9kZXNcbiAgICB0aGlzLnNjaGVkdWxlUmVuZGVyLmNhbmNlbD8uKClcbiAgICBpZiAodGhpcy5kcmFpblRpbWVyICE9PSBudWxsKSB7XG4gICAgICBjbGVhclRpbWVvdXQodGhpcy5kcmFpblRpbWVyKVxuICAgICAgdGhpcy5kcmFpblRpbWVyID0gbnVsbFxuICAgIH1cblxuICAgIC8vIEB0cy1leHBlY3QtZXJyb3IgdXBkYXRlQ29udGFpbmVyU3luYyBleGlzdHMgaW4gcmVhY3QtcmVjb25jaWxlciBidXQgbm90IGluIEB0eXBlcy9yZWFjdC1yZWNvbmNpbGVyXG4gICAgcmVjb25jaWxlci51cGRhdGVDb250YWluZXJTeW5jKG51bGwsIHRoaXMuY29udGFpbmVyLCBudWxsLCBub29wKVxuICAgIC8vIEB0cy1leHBlY3QtZXJyb3IgZmx1c2hTeW5jV29yayBleGlzdHMgaW4gcmVhY3QtcmVjb25jaWxlciBidXQgbm90IGluIEB0eXBlcy9yZWFjdC1yZWNvbmNpbGVyXG4gICAgcmVjb25jaWxlci5mbHVzaFN5bmNXb3JrKClcbiAgICBpbnN0YW5jZXMuZGVsZXRlKHRoaXMub3B0aW9ucy5zdGRvdXQpXG5cbiAgICAvLyBGcmVlIHRoZSByb290IHlvZ2Egbm9kZSwgdGhlbiBjbGVhciBpdHMgcmVmZXJlbmNlLiBDaGlsZHJlbiBhcmUgYWxyZWFkeVxuICAgIC8vIGZyZWVkIGJ5IHRoZSByZWNvbmNpbGVyJ3MgcmVtb3ZlQ2hpbGRGcm9tQ29udGFpbmVyOyB1c2luZyAuZnJlZSgpIChub3RcbiAgICAvLyAuZnJlZVJlY3Vyc2l2ZSgpKSBhdm9pZHMgZG91YmxlLWZyZWVpbmcgdGhlbS5cbiAgICB0aGlzLnJvb3ROb2RlLnlvZ2FOb2RlPy5mcmVlKClcbiAgICB0aGlzLnJvb3ROb2RlLnlvZ2FOb2RlID0gdW5kZWZpbmVkXG5cbiAgICBpZiAoZXJyb3IgaW5zdGFuY2VvZiBFcnJvcikge1xuICAgICAgdGhpcy5yZWplY3RFeGl0UHJvbWlzZShlcnJvcilcbiAgICB9IGVsc2Uge1xuICAgICAgdGhpcy5yZXNvbHZlRXhpdFByb21pc2UoKVxuICAgIH1cbiAgfVxuXG4gIGFzeW5jIHdhaXRVbnRpbEV4aXQoKTogUHJvbWlzZTx2b2lkPiB7XG4gICAgdGhpcy5leGl0UHJvbWlzZSB8fD0gbmV3IFByb21pc2UoKHJlc29sdmUsIHJlamVjdCkgPT4ge1xuICAgICAgdGhpcy5yZXNvbHZlRXhpdFByb21pc2UgPSByZXNvbHZlXG4gICAgICB0aGlzLnJlamVjdEV4aXRQcm9taXNlID0gcmVqZWN0XG4gICAgfSlcblxuICAgIHJldHVybiB0aGlzLmV4aXRQcm9taXNlXG4gIH1cblxuICByZXNldExpbmVDb3VudCgpOiB2b2lkIHtcbiAgICBpZiAodGhpcy5vcHRpb25zLnN0ZG91dC5pc1RUWSkge1xuICAgICAgLy8gU3dhcCBzbyBvbGQgZnJvbnQgYmVjb21lcyBiYWNrIChmb3Igc2NyZWVuIHJldXNlKSwgdGhlbiByZXNldCBmcm9udFxuICAgICAgdGhpcy5iYWNrRnJhbWUgPSB0aGlzLmZyb250RnJhbWVcbiAgICAgIHRoaXMuZnJvbnRGcmFtZSA9IGVtcHR5RnJhbWUoXG4gICAgICAgIHRoaXMuZnJvbnRGcmFtZS52aWV3cG9ydC5oZWlnaHQsXG4gICAgICAgIHRoaXMuZnJvbnRGcmFtZS52aWV3cG9ydC53aWR0aCxcbiAgICAgICAgdGhpcy5zdHlsZVBvb2wsXG4gICAgICAgIHRoaXMuY2hhclBvb2wsXG4gICAgICAgIHRoaXMuaHlwZXJsaW5rUG9vbCxcbiAgICAgIClcbiAgICAgIHRoaXMubG9nLnJlc2V0KClcbiAgICAgIC8vIGZyb250RnJhbWUgaXMgcmVzZXQsIHNvIGZyYW1lLmN1cnNvciBvbiB0aGUgbmV4dCByZW5kZXIgaXMgKDAsMCkuXG4gICAgICAvLyBDbGVhciBkaXNwbGF5Q3Vyc29yIHNvIHRoZSBwcmVhbWJsZSBkb2Vzbid0IGNvbXB1dGUgYSBzdGFsZSBkZWx0YS5cbiAgICAgIHRoaXMuZGlzcGxheUN1cnNvciA9IG51bGxcbiAgICB9XG4gIH1cblxuICAvKipcbiAgICogUmVwbGFjZSBjaGFyL2h5cGVybGluayBwb29scyB3aXRoIGZyZXNoIGluc3RhbmNlcyB0byBwcmV2ZW50IHVuYm91bmRlZFxuICAgKiBncm93dGggZHVyaW5nIGxvbmcgc2Vzc2lvbnMuIE1pZ3JhdGVzIHRoZSBmcm9udCBmcmFtZSdzIHNjcmVlbiBJRHMgaW50b1xuICAgKiB0aGUgbmV3IHBvb2xzIHNvIGRpZmZpbmcgcmVtYWlucyBjb3JyZWN0LiBUaGUgYmFjayBmcmFtZSBkb2Vzbid0IG5lZWRcbiAgICogbWlncmF0aW9uIOKAlCByZXNldFNjcmVlbiB6ZXJvcyBpdCBiZWZvcmUgYW55IHJlYWRzLlxuICAgKlxuICAgKiBDYWxsIGJldHdlZW4gY29udmVyc2F0aW9uIHR1cm5zIG9yIHBlcmlvZGljYWxseS5cbiAgICovXG4gIHJlc2V0UG9vbHMoKTogdm9pZCB7XG4gICAgdGhpcy5jaGFyUG9vbCA9IG5ldyBDaGFyUG9vbCgpXG4gICAgdGhpcy5oeXBlcmxpbmtQb29sID0gbmV3IEh5cGVybGlua1Bvb2woKVxuICAgIG1pZ3JhdGVTY3JlZW5Qb29scyhcbiAgICAgIHRoaXMuZnJvbnRGcmFtZS5zY3JlZW4sXG4gICAgICB0aGlzLmNoYXJQb29sLFxuICAgICAgdGhpcy5oeXBlcmxpbmtQb29sLFxuICAgIClcbiAgICAvLyBCYWNrIGZyYW1lJ3MgZGF0YSBpcyB6ZXJvZWQgYnkgcmVzZXRTY3JlZW4gYmVmb3JlIHJlYWRzLCBidXQgaXRzIHBvb2xcbiAgICAvLyByZWZlcmVuY2VzIGFyZSB1c2VkIGJ5IHRoZSByZW5kZXJlciB0byBpbnRlcm4gbmV3IGNoYXJhY3RlcnMuIFBvaW50XG4gICAgLy8gdGhlbSBhdCB0aGUgbmV3IHBvb2xzIHNvIHRoZSBuZXh0IGZyYW1lJ3MgSURzIGFyZSBjb21wYXJhYmxlLlxuICAgIHRoaXMuYmFja0ZyYW1lLnNjcmVlbi5jaGFyUG9vbCA9IHRoaXMuY2hhclBvb2xcbiAgICB0aGlzLmJhY2tGcmFtZS5zY3JlZW4uaHlwZXJsaW5rUG9vbCA9IHRoaXMuaHlwZXJsaW5rUG9vbFxuICB9XG5cbiAgcGF0Y2hDb25zb2xlKCk6ICgpID0+IHZvaWQge1xuICAgIC8vIGJpb21lLWlnbm9yZSBsaW50L3N1c3BpY2lvdXMvbm9Db25zb2xlOiBpbnRlbnRpb25hbGx5IHBhdGNoaW5nIGdsb2JhbCBjb25zb2xlXG4gICAgY29uc3QgY29uID0gY29uc29sZVxuICAgIGNvbnN0IG9yaWdpbmFsczogUGFydGlhbDxSZWNvcmQ8a2V5b2YgQ29uc29sZSwgQ29uc29sZVtrZXlvZiBDb25zb2xlXT4+ID0ge31cbiAgICBjb25zdCB0b0RlYnVnID0gKC4uLmFyZ3M6IHVua25vd25bXSkgPT5cbiAgICAgIGxvZ0ZvckRlYnVnZ2luZyhgY29uc29sZS5sb2c6ICR7Zm9ybWF0KC4uLmFyZ3MpfWApXG4gICAgY29uc3QgdG9FcnJvciA9ICguLi5hcmdzOiB1bmtub3duW10pID0+XG4gICAgICBsb2dFcnJvcihuZXcgRXJyb3IoYGNvbnNvbGUuZXJyb3I6ICR7Zm9ybWF0KC4uLmFyZ3MpfWApKVxuICAgIGZvciAoY29uc3QgbSBvZiBDT05TT0xFX1NURE9VVF9NRVRIT0RTKSB7XG4gICAgICBvcmlnaW5hbHNbbV0gPSBjb25bbV1cbiAgICAgIGNvblttXSA9IHRvRGVidWdcbiAgICB9XG4gICAgZm9yIChjb25zdCBtIG9mIENPTlNPTEVfU1RERVJSX01FVEhPRFMpIHtcbiAgICAgIG9yaWdpbmFsc1ttXSA9IGNvblttXVxuICAgICAgY29uW21dID0gdG9FcnJvclxuICAgIH1cbiAgICBvcmlnaW5hbHMuYXNzZXJ0ID0gY29uLmFzc2VydFxuICAgIGNvbi5hc3NlcnQgPSAoY29uZGl0aW9uOiB1bmtub3duLCAuLi5hcmdzOiB1bmtub3duW10pID0+IHtcbiAgICAgIGlmICghY29uZGl0aW9uKSB0b0Vycm9yKC4uLmFyZ3MpXG4gICAgfVxuICAgIHJldHVybiAoKSA9PiBPYmplY3QuYXNzaWduKGNvbiwgb3JpZ2luYWxzKVxuICB9XG5cbiAgLyoqXG4gICAqIEludGVyY2VwdCBwcm9jZXNzLnN0ZGVyci53cml0ZSBzbyBzdHJheSB3cml0ZXMgKGNvbmZpZy50cywgaG9va3MudHMsXG4gICAqIHRoaXJkLXBhcnR5IGRlcHMpIGRvbid0IGNvcnJ1cHQgdGhlIGFsdC1zY3JlZW4gYnVmZmVyLiBwYXRjaENvbnNvbGUgb25seVxuICAgKiBob29rcyBjb25zb2xlLiogbWV0aG9kcyDigJQgZGlyZWN0IHN0ZGVyciB3cml0ZXMgYnlwYXNzIGl0LCBsYW5kIGF0IHRoZVxuICAgKiBwYXJrZWQgY3Vyc29yLCBzY3JvbGwgdGhlIGFsdC1zY3JlZW4sIGFuZCBkZXN5bmMgZnJvbnRGcmFtZSBmcm9tIHRoZVxuICAgKiBwaHlzaWNhbCB0ZXJtaW5hbC4gTmV4dCBkaWZmIHdyaXRlcyBvbmx5IGNoYW5nZWQtaW4tUmVhY3QgY2VsbHMgYXRcbiAgICogYWJzb2x1dGUgY29vcmRzIOKGkiBpbnRlcmxlYXZlZCBnYXJiYWdlLlxuICAgKlxuICAgKiBTd2FsbG93cyB0aGUgd3JpdGUgKHJvdXRlcyB0ZXh0IHRvIHRoZSBkZWJ1ZyBsb2cpIGFuZCwgaW4gYWx0LXNjcmVlbixcbiAgICogZm9yY2VzIGEgZnVsbC1kYW1hZ2UgcmVwYWludCBhcyBhIGRlZmVuc2l2ZSByZWNvdmVyeS4gTm90IHBhdGNoaW5nXG4gICAqIHByb2Nlc3Muc3Rkb3V0IOKAlCBJbmsgaXRzZWxmIHdyaXRlcyB0aGVyZS5cbiAgICovXG4gIHByaXZhdGUgcGF0Y2hTdGRlcnIoKTogKCkgPT4gdm9pZCB7XG4gICAgY29uc3Qgc3RkZXJyID0gcHJvY2Vzcy5zdGRlcnJcbiAgICBjb25zdCBvcmlnaW5hbFdyaXRlID0gc3RkZXJyLndyaXRlXG4gICAgbGV0IHJlZW50ZXJlZCA9IGZhbHNlXG4gICAgY29uc3QgaW50ZXJjZXB0ID0gKFxuICAgICAgY2h1bms6IFVpbnQ4QXJyYXkgfCBzdHJpbmcsXG4gICAgICBlbmNvZGluZ09yQ2I/OiBCdWZmZXJFbmNvZGluZyB8ICgoZXJyPzogRXJyb3IpID0+IHZvaWQpLFxuICAgICAgY2I/OiAoZXJyPzogRXJyb3IpID0+IHZvaWQsXG4gICAgKTogYm9vbGVhbiA9PiB7XG4gICAgICBjb25zdCBjYWxsYmFjayA9IHR5cGVvZiBlbmNvZGluZ09yQ2IgPT09ICdmdW5jdGlvbicgPyBlbmNvZGluZ09yQ2IgOiBjYlxuICAgICAgLy8gUmVlbnRyYW5jeSBndWFyZDogbG9nRm9yRGVidWdnaW5nIOKGkiB3cml0ZVRvU3RkZXJyIOKGkiBoZXJlLiBQYXNzXG4gICAgICAvLyB0aHJvdWdoIHRvIHRoZSBvcmlnaW5hbCBzbyAtLWRlYnVnLXRvLXN0ZGVyciBzdGlsbCB3b3JrcyBhbmQgd2VcbiAgICAgIC8vIGRvbid0IHN0YWNrLW92ZXJmbG93LlxuICAgICAgaWYgKHJlZW50ZXJlZCkge1xuICAgICAgICBjb25zdCBlbmNvZGluZyA9XG4gICAgICAgICAgdHlwZW9mIGVuY29kaW5nT3JDYiA9PT0gJ3N0cmluZycgPyBlbmNvZGluZ09yQ2IgOiB1bmRlZmluZWRcbiAgICAgICAgcmV0dXJuIG9yaWdpbmFsV3JpdGUuY2FsbChzdGRlcnIsIGNodW5rLCBlbmNvZGluZywgY2FsbGJhY2spXG4gICAgICB9XG4gICAgICByZWVudGVyZWQgPSB0cnVlXG4gICAgICB0cnkge1xuICAgICAgICBjb25zdCB0ZXh0ID1cbiAgICAgICAgICB0eXBlb2YgY2h1bmsgPT09ICdzdHJpbmcnXG4gICAgICAgICAgICA/IGNodW5rXG4gICAgICAgICAgICA6IEJ1ZmZlci5mcm9tKGNodW5rKS50b1N0cmluZygndXRmOCcpXG4gICAgICAgIGxvZ0ZvckRlYnVnZ2luZyhgW3N0ZGVycl0gJHt0ZXh0fWAsIHsgbGV2ZWw6ICd3YXJuJyB9KVxuICAgICAgICBpZiAodGhpcy5hbHRTY3JlZW5BY3RpdmUgJiYgIXRoaXMuaXNVbm1vdW50ZWQgJiYgIXRoaXMuaXNQYXVzZWQpIHtcbiAgICAgICAgICB0aGlzLnByZXZGcmFtZUNvbnRhbWluYXRlZCA9IHRydWVcbiAgICAgICAgICB0aGlzLnNjaGVkdWxlUmVuZGVyKClcbiAgICAgICAgfVxuICAgICAgfSBmaW5hbGx5IHtcbiAgICAgICAgcmVlbnRlcmVkID0gZmFsc2VcbiAgICAgICAgY2FsbGJhY2s/LigpXG4gICAgICB9XG4gICAgICByZXR1cm4gdHJ1ZVxuICAgIH1cbiAgICBzdGRlcnIud3JpdGUgPSBpbnRlcmNlcHRcbiAgICByZXR1cm4gKCkgPT4ge1xuICAgICAgaWYgKHN0ZGVyci53cml0ZSA9PT0gaW50ZXJjZXB0KSB7XG4gICAgICAgIHN0ZGVyci53cml0ZSA9IG9yaWdpbmFsV3JpdGVcbiAgICAgIH1cbiAgICB9XG4gIH1cbn1cblxuLyoqXG4gKiBEaXNjYXJkIHBlbmRpbmcgc3RkaW4gYnl0ZXMgc28gaW4tZmxpZ2h0IGVzY2FwZSBzZXF1ZW5jZXMgKG1vdXNlIHRyYWNraW5nXG4gKiByZXBvcnRzLCBicmFja2V0ZWQtcGFzdGUgbWFya2VycykgZG9uJ3QgbGVhayB0byB0aGUgc2hlbGwgYWZ0ZXIgZXhpdC5cbiAqXG4gKiBUd28gbGF5ZXJzIG9mIHRyaWNraW5lc3M6XG4gKlxuICogMS4gc2V0UmF3TW9kZSBpcyB0ZXJtaW9zLCBub3QgZmNudGwg4oCUIHRoZSBzdGRpbiBmZCBzdGF5cyBibG9ja2luZywgc29cbiAqICAgIHJlYWRTeW5jIG9uIGl0IHdvdWxkIGhhbmcgZm9yZXZlci4gTm9kZSBkb2Vzbid0IGV4cG9zZSBmY250bCwgc28gd2VcbiAqICAgIG9wZW4gL2Rldi90dHkgZnJlc2ggd2l0aCBPX05PTkJMT0NLIChhbGwgZmRzIHRvIHRoZSBjb250cm9sbGluZ1xuICogICAgdGVybWluYWwgc2hhcmUgb25lIGxpbmUtZGlzY2lwbGluZSBpbnB1dCBxdWV1ZSkuXG4gKlxuICogMi4gQnkgdGhlIHRpbWUgZm9yY2VFeGl0IGNhbGxzIHRoaXMsIGRldGFjaEZvclNodXRkb3duIGhhcyBhbHJlYWR5IHB1dFxuICogICAgdGhlIFRUWSBiYWNrIGluIGNvb2tlZCAoY2Fub25pY2FsKSBtb2RlLiBDYW5vbmljYWwgbW9kZSBsaW5lLWJ1ZmZlcnNcbiAqICAgIGlucHV0IHVudGlsIG5ld2xpbmUsIHNvIE9fTk9OQkxPQ0sgcmVhZHMgcmV0dXJuIEVBR0FJTiBldmVuIHdoZW5cbiAqICAgIG1vdXNlIGJ5dGVzIGFyZSBzaXR0aW5nIGluIHRoZSBidWZmZXIuIFdlIGJyaWVmbHkgcmUtZW50ZXIgcmF3IG1vZGVcbiAqICAgIHNvIHJlYWRzIHJldHVybiBhbnkgYXZhaWxhYmxlIGJ5dGVzLCB0aGVuIHJlc3RvcmUgY29va2VkIG1vZGUuXG4gKlxuICogU2FmZSB0byBjYWxsIG11bHRpcGxlIHRpbWVzLiBDYWxsIGFzIExBVEUgYXMgcG9zc2libGUgaW4gdGhlIGV4aXQgcGF0aDpcbiAqIERJU0FCTEVfTU9VU0VfVFJBQ0tJTkcgaGFzIHRlcm1pbmFsIHJvdW5kLXRyaXAgbGF0ZW5jeSwgc28gZXZlbnRzIGNhblxuICogYXJyaXZlIGZvciBhIGZldyBtcyBhZnRlciBpdCdzIHdyaXR0ZW4uXG4gKi9cbi8qIGVzbGludC1kaXNhYmxlIGN1c3RvbS1ydWxlcy9uby1zeW5jLWZzIC0tIG11c3QgYmUgc3luYzsgY2FsbGVkIGZyb20gc2lnbmFsIGhhbmRsZXIgLyB1bm1vdW50ICovXG5leHBvcnQgZnVuY3Rpb24gZHJhaW5TdGRpbihzdGRpbjogTm9kZUpTLlJlYWRTdHJlYW0gPSBwcm9jZXNzLnN0ZGluKTogdm9pZCB7XG4gIGlmICghc3RkaW4uaXNUVFkpIHJldHVyblxuICAvLyBEcmFpbiBOb2RlJ3Mgc3RyZWFtIGJ1ZmZlciAoYnl0ZXMgbGlidXYgYWxyZWFkeSBwdWxsZWQgaW4pLiByZWFkKClcbiAgLy8gcmV0dXJucyBudWxsIHdoZW4gZW1wdHkg4oCUIG5ldmVyIGJsb2Nrcy5cbiAgdHJ5IHtcbiAgICB3aGlsZSAoc3RkaW4ucmVhZCgpICE9PSBudWxsKSB7XG4gICAgICAvKiBkaXNjYXJkICovXG4gICAgfVxuICB9IGNhdGNoIHtcbiAgICAvKiBzdHJlYW0gbWF5IGJlIGRlc3Ryb3llZCAqL1xuICB9XG4gIC8vIE5vIC9kZXYvdHR5IG9uIFdpbmRvd3M7IENPTklOJCBkb2Vzbid0IHN1cHBvcnQgT19OT05CTE9DSyBzZW1hbnRpY3MuXG4gIC8vIFdpbmRvd3MgVGVybWluYWwgYWxzbyBkb2Vzbid0IGJ1ZmZlciBtb3VzZSByZXBvcnRzIHRoZSBzYW1lIHdheS5cbiAgaWYgKHByb2Nlc3MucGxhdGZvcm0gPT09ICd3aW4zMicpIHJldHVyblxuICAvLyB0ZXJtaW9zIGlzIHBlci1kZXZpY2U6IGZsaXAgc3RkaW4gdG8gcmF3IHNvIGNhbm9uaWNhbC1tb2RlIGxpbmVcbiAgLy8gYnVmZmVyaW5nIGRvZXNuJ3QgaGlkZSBwYXJ0aWFsIGlucHV0IGZyb20gdGhlIG5vbi1ibG9ja2luZyByZWFkLlxuICAvLyBSZXN0b3JlZCBpbiB0aGUgZmluYWxseSBibG9jay5cbiAgY29uc3QgdHR5ID0gc3RkaW4gYXMgTm9kZUpTLlJlYWRTdHJlYW0gJiB7XG4gICAgaXNSYXc/OiBib29sZWFuXG4gICAgc2V0UmF3TW9kZT86IChyYXc6IGJvb2xlYW4pID0+IHZvaWRcbiAgfVxuICBjb25zdCB3YXNSYXcgPSB0dHkuaXNSYXcgPT09IHRydWVcbiAgLy8gRHJhaW4gdGhlIGtlcm5lbCBUVFkgYnVmZmVyIHZpYSBhIGZyZXNoIE9fTk9OQkxPQ0sgZmQuIEJvdW5kZWQgYXQgNjRcbiAgLy8gcmVhZHMgKDY0S0IpIOKAlCBhIHJlYWwgbW91c2UgYnVyc3QgaXMgYSBmZXcgaHVuZHJlZCBieXRlczsgdGhlIGNhcFxuICAvLyBndWFyZHMgYWdhaW5zdCBhIHRlcm1pbmFsIHRoYXQgaWdub3JlcyBPX05PTkJMT0NLLlxuICBsZXQgZmQgPSAtMVxuICB0cnkge1xuICAgIC8vIHNldFJhd01vZGUgaW5zaWRlIHRyeTogb24gcmV2b2tlZCBUVFkgKFNJR0hVUC9TU0ggZGlzY29ubmVjdCkgdGhlXG4gICAgLy8gaW9jdGwgdGhyb3dzIEVCQURGIOKAlCBzYW1lIHJlY292ZXJ5IHBhdGggYXMgb3BlblN5bmMvcmVhZFN5bmMgYmVsb3cuXG4gICAgaWYgKCF3YXNSYXcpIHR0eS5zZXRSYXdNb2RlPy4odHJ1ZSlcbiAgICBmZCA9IG9wZW5TeW5jKCcvZGV2L3R0eScsIGZzQ29uc3RhbnRzLk9fUkRPTkxZIHwgZnNDb25zdGFudHMuT19OT05CTE9DSylcbiAgICBjb25zdCBidWYgPSBCdWZmZXIuYWxsb2MoMTAyNClcbiAgICBmb3IgKGxldCBpID0gMDsgaSA8IDY0OyBpKyspIHtcbiAgICAgIGlmIChyZWFkU3luYyhmZCwgYnVmLCAwLCBidWYubGVuZ3RoLCBudWxsKSA8PSAwKSBicmVha1xuICAgIH1cbiAgfSBjYXRjaCB7XG4gICAgLy8gRUFHQUlOIChidWZmZXIgZW1wdHkg4oCUIGV4cGVjdGVkKSwgRU5YSU8vRU5PRU5UIChubyBjb250cm9sbGluZyB0dHkpLFxuICAgIC8vIEVCQURGL0VJTyAoVFRZIHJldm9rZWQg4oCUIFNJR0hVUCwgU1NIIGRpc2Nvbm5lY3QpXG4gIH0gZmluYWxseSB7XG4gICAgaWYgKGZkID49IDApIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNsb3NlU3luYyhmZClcbiAgICAgIH0gY2F0Y2gge1xuICAgICAgICAvKiBpZ25vcmUgKi9cbiAgICAgIH1cbiAgICB9XG4gICAgaWYgKCF3YXNSYXcpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIHR0eS5zZXRSYXdNb2RlPy4oZmFsc2UpXG4gICAgICB9IGNhdGNoIHtcbiAgICAgICAgLyogVFRZIG1heSBiZSBnb25lICovXG4gICAgICB9XG4gICAgfVxuICB9XG59XG4vKiBlc2xpbnQtZW5hYmxlIGN1c3RvbS1ydWxlcy9uby1zeW5jLWZzICovXG5cbmNvbnN0IENPTlNPTEVfU1RET1VUX01FVEhPRFMgPSBbXG4gICdsb2cnLFxuICAnaW5mbycsXG4gICdkZWJ1ZycsXG4gICdkaXInLFxuICAnZGlyeG1sJyxcbiAgJ2NvdW50JyxcbiAgJ2NvdW50UmVzZXQnLFxuICAnZ3JvdXAnLFxuICAnZ3JvdXBDb2xsYXBzZWQnLFxuICAnZ3JvdXBFbmQnLFxuICAndGFibGUnLFxuICAndGltZScsXG4gICd0aW1lRW5kJyxcbiAgJ3RpbWVMb2cnLFxuXSBhcyBjb25zdFxuY29uc3QgQ09OU09MRV9TVERFUlJfTUVUSE9EUyA9IFsnd2FybicsICdlcnJvcicsICd0cmFjZSddIGFzIGNvbnN0XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU9BLFFBQVEsTUFBTSxXQUFXO0FBQ2hDLFNBQ0VDLFNBQVMsRUFDVEMsU0FBUyxJQUFJQyxXQUFXLEVBQ3hCQyxRQUFRLEVBQ1JDLFFBQVEsRUFDUkMsU0FBUyxRQUNKLElBQUk7QUFDWCxPQUFPQyxJQUFJLE1BQU0sbUJBQW1CO0FBQ3BDLE9BQU9DLFFBQVEsTUFBTSx1QkFBdUI7QUFDNUMsT0FBT0MsS0FBSyxJQUFJLEtBQUtDLFNBQVMsUUFBUSxPQUFPO0FBQzdDLGNBQWNDLFNBQVMsUUFBUSxrQkFBa0I7QUFDakQsU0FBU0MsY0FBYyxRQUFRLCtCQUErQjtBQUM5RCxTQUFTQyxNQUFNLFFBQVEsYUFBYTtBQUNwQyxTQUFTQyxvQkFBb0IsUUFBUSx3QkFBd0I7QUFDN0QsU0FBU0MsZUFBZSxRQUFRLG9DQUFvQztBQUNwRSxTQUFTQyxlQUFlLFFBQVEsb0JBQW9CO0FBQ3BELFNBQVNDLFFBQVEsUUFBUSxrQkFBa0I7QUFDM0MsU0FBU0MsTUFBTSxRQUFRLE1BQU07QUFDN0IsU0FBU0MsUUFBUSxRQUFRLGVBQWU7QUFDeEMsT0FBT0MsR0FBRyxNQUFNLHFCQUFxQjtBQUNyQyxjQUNFQyxpQkFBaUIsRUFDakJDLHVCQUF1QixRQUNsQiwwQ0FBMEM7QUFDakQsU0FBU0MsaUJBQWlCLFFBQVEsZ0JBQWdCO0FBQ2xELE9BQU8sS0FBS0MsR0FBRyxNQUFNLFVBQVU7QUFDL0IsU0FBU0MsYUFBYSxRQUFRLDRCQUE0QjtBQUMxRCxTQUFTQyxZQUFZLFFBQVEsWUFBWTtBQUN6QyxTQUFTQyxVQUFVLEVBQUUsS0FBS0MsS0FBSyxFQUFFLEtBQUtDLFVBQVUsUUFBUSxZQUFZO0FBQ3BFLFNBQVNDLGFBQWEsRUFBRUMsYUFBYSxRQUFRLGVBQWU7QUFDNUQsT0FBT0MsU0FBUyxNQUFNLGdCQUFnQjtBQUN0QyxTQUFTQyxTQUFTLFFBQVEsaUJBQWlCO0FBQzNDLFNBQVNDLFNBQVMsUUFBUSxpQkFBaUI7QUFDM0MsU0FBU0MsUUFBUSxRQUFRLGdCQUFnQjtBQUN6QyxPQUFPQyxNQUFNLE1BQU0sYUFBYTtBQUNoQyxjQUFjQyxTQUFTLFFBQVEscUJBQXFCO0FBQ3BELE9BQU9DLFVBQVUsSUFDZkMsVUFBVSxFQUNWQyxlQUFlLEVBQ2ZDLGFBQWEsRUFDYkMsc0JBQXNCLEVBQ3RCQyxZQUFZLEVBQ1pDLG9CQUFvQixRQUNmLGlCQUFpQjtBQUN4QixPQUFPQyxrQkFBa0IsSUFDdkJDLG1CQUFtQixFQUNuQkMsY0FBYyxRQUNULDRCQUE0QjtBQUNuQyxTQUNFQyx3QkFBd0IsRUFDeEIsS0FBS0MsYUFBYSxFQUNsQkMsYUFBYSxRQUNSLHVCQUF1QjtBQUM5QixPQUFPQyxjQUFjLElBQUksS0FBS0MsUUFBUSxRQUFRLGVBQWU7QUFDN0QsU0FDRUMsU0FBUyxFQUNUQyxRQUFRLEVBQ1JDLE1BQU0sRUFDTkMsWUFBWSxFQUNaQyxhQUFhLEVBQ2JDLGFBQWEsRUFDYkMsa0JBQWtCLEVBQ2xCQyxTQUFTLFFBQ0osYUFBYTtBQUNwQixTQUFTQyxvQkFBb0IsUUFBUSxzQkFBc0I7QUFDM0QsU0FDRUMscUJBQXFCLEVBQ3JCQyxtQkFBbUIsRUFDbkJDLGNBQWMsRUFDZEMsb0JBQW9CLEVBQ3BCQyxlQUFlLEVBQ2YsS0FBS0MsU0FBUyxFQUNkQyxrQkFBa0IsRUFDbEJDLGVBQWUsRUFDZkMsWUFBWSxFQUNaQyxTQUFTLEVBQ1QsS0FBS0MsY0FBYyxFQUNuQkMsWUFBWSxFQUNaQyxZQUFZLEVBQ1pDLFdBQVcsRUFDWEMsY0FBYyxFQUNkQyx1QkFBdUIsRUFDdkJDLGNBQWMsRUFDZEMsZUFBZSxRQUNWLGdCQUFnQjtBQUN2QixTQUNFQyxxQkFBcUIsRUFDckJDLG9CQUFvQixFQUNwQixLQUFLQyxRQUFRLEVBQ2JDLG1CQUFtQixRQUNkLGVBQWU7QUFDdEIsU0FDRUMsV0FBVyxFQUNYQyxVQUFVLEVBQ1ZDLGNBQWMsRUFDZEMsc0JBQXNCLEVBQ3RCQyx5QkFBeUIsRUFDekJDLHFCQUFxQixFQUNyQkMsd0JBQXdCLEVBQ3hCQyxZQUFZLFFBQ1AsaUJBQWlCO0FBQ3hCLFNBQ0VDLEdBQUcsRUFDSEMsR0FBRyxFQUNIQyxzQkFBc0IsRUFDdEJDLHFCQUFxQixFQUNyQkMsZ0JBQWdCLEVBQ2hCQyxlQUFlLEVBQ2ZDLFdBQVcsUUFDTixpQkFBaUI7QUFDeEIsU0FDRUMscUJBQXFCLEVBQ3JCQyxnQkFBZ0IsRUFDaEJDLFlBQVksRUFDWkMsaUJBQWlCLEVBQ2pCQyxrQkFBa0IsUUFDYixpQkFBaUI7QUFDeEIsU0FBU0MscUJBQXFCLFFBQVEsOEJBQThCOztBQUVwRTtBQUNBO0FBQ0E7QUFDQSxNQUFNQyx3QkFBd0IsR0FBR0MsTUFBTSxDQUFDQyxNQUFNLENBQUM7RUFBRUMsQ0FBQyxFQUFFLENBQUM7RUFBRUMsQ0FBQyxFQUFFLENBQUM7RUFBRUMsT0FBTyxFQUFFO0FBQU0sQ0FBQyxDQUFDO0FBQzlFLE1BQU1DLGlCQUFpQixHQUFHTCxNQUFNLENBQUNDLE1BQU0sQ0FBQztFQUN0Q0ssSUFBSSxFQUFFLFFBQVEsSUFBSUMsS0FBSztFQUN2QkMsT0FBTyxFQUFFOUI7QUFDWCxDQUFDLENBQUM7QUFDRixNQUFNK0IscUJBQXFCLEdBQUdULE1BQU0sQ0FBQ0MsTUFBTSxDQUFDO0VBQzFDSyxJQUFJLEVBQUUsUUFBUSxJQUFJQyxLQUFLO0VBQ3ZCQyxPQUFPLEVBQUV2QixZQUFZLEdBQUdQO0FBQzFCLENBQUMsQ0FBQzs7QUFFRjtBQUNBO0FBQ0EsU0FBU2dDLHNCQUFzQkEsQ0FBQ0MsWUFBWSxFQUFFLE1BQU0sRUFBRTtFQUNwRCxPQUFPWCxNQUFNLENBQUNDLE1BQU0sQ0FBQztJQUNuQkssSUFBSSxFQUFFLFFBQVEsSUFBSUMsS0FBSztJQUN2QkMsT0FBTyxFQUFFNUIsY0FBYyxDQUFDK0IsWUFBWSxFQUFFLENBQUM7RUFDekMsQ0FBQyxDQUFDO0FBQ0o7QUFFQSxPQUFPLEtBQUtDLE9BQU8sR0FBRztFQUNwQkMsTUFBTSxFQUFFQyxNQUFNLENBQUNDLFdBQVc7RUFDMUJDLEtBQUssRUFBRUYsTUFBTSxDQUFDRyxVQUFVO0VBQ3hCQyxNQUFNLEVBQUVKLE1BQU0sQ0FBQ0MsV0FBVztFQUMxQkksV0FBVyxFQUFFLE9BQU87RUFDcEJDLFlBQVksRUFBRSxPQUFPO0VBQ3JCQyxhQUFhLENBQUMsRUFBRSxHQUFHLEdBQUdDLE9BQU8sQ0FBQyxJQUFJLENBQUM7RUFDbkNDLE9BQU8sQ0FBQyxFQUFFLENBQUNDLEtBQUssRUFBRXJHLFVBQVUsRUFBRSxHQUFHLElBQUk7QUFDdkMsQ0FBQztBQUVELGVBQWUsTUFBTXNHLEdBQUcsQ0FBQztFQUN2QixpQkFBaUJDLEdBQUcsRUFBRW5HLFNBQVM7RUFDL0IsaUJBQWlCb0csUUFBUSxFQUFFbkQsUUFBUTtFQUNuQyxRQUFRb0QsY0FBYyxFQUFFLENBQUMsR0FBRyxHQUFHLElBQUksQ0FBQyxHQUFHO0lBQUVDLE1BQU0sQ0FBQyxFQUFFLEdBQUcsR0FBRyxJQUFJO0VBQUMsQ0FBQztFQUM5RDtFQUNBLFFBQVFDLFdBQVcsR0FBRyxLQUFLO0VBQzNCLFFBQVFDLFFBQVEsR0FBRyxLQUFLO0VBQ3hCLGlCQUFpQkMsU0FBUyxFQUFFL0gsU0FBUztFQUNyQyxRQUFRZ0ksUUFBUSxFQUFFbkgsR0FBRyxDQUFDb0gsVUFBVTtFQUNoQyxTQUFTQyxZQUFZLEVBQUVuSCxZQUFZO0VBQ25DLFFBQVFvSCxRQUFRLEVBQUUxRixRQUFRO0VBQzFCLGlCQUFpQjJGLFNBQVMsRUFBRW5GLFNBQVM7RUFDckMsUUFBUW9GLFFBQVEsRUFBRTFGLFFBQVE7RUFDMUIsUUFBUTJGLGFBQWEsRUFBRXhGLGFBQWE7RUFDcEMsUUFBUXlGLFdBQVcsQ0FBQyxFQUFFbEIsT0FBTyxDQUFDLElBQUksQ0FBQztFQUNuQyxRQUFRbUIsY0FBYyxDQUFDLEVBQUUsR0FBRyxHQUFHLElBQUk7RUFDbkMsUUFBUUMsYUFBYSxDQUFDLEVBQUUsR0FBRyxHQUFHLElBQUk7RUFDbEMsaUJBQWlCQyxzQkFBc0IsQ0FBQyxFQUFFLEdBQUcsR0FBRyxJQUFJO0VBQ3BELFFBQVFDLGVBQWUsRUFBRSxNQUFNO0VBQy9CLFFBQVFqQyxZQUFZLEVBQUUsTUFBTTtFQUM1QixRQUFRa0MsV0FBVyxFQUFFN0ksU0FBUyxHQUFHLElBQUk7RUFDckMsUUFBUThJLFVBQVUsRUFBRTVILEtBQUs7RUFDekIsUUFBUTZILFNBQVMsRUFBRTdILEtBQUs7RUFDeEIsUUFBUThILGlCQUFpQixHQUFHQyxXQUFXLENBQUNDLEdBQUcsQ0FBQyxDQUFDO0VBQzdDLFFBQVFDLFVBQVUsRUFBRUMsVUFBVSxDQUFDLE9BQU9DLFVBQVUsQ0FBQyxHQUFHLElBQUksR0FBRyxJQUFJO0VBQy9ELFFBQVFDLGdCQUFnQixFQUFFO0lBQ3hCQyxFQUFFLEVBQUUsTUFBTTtJQUNWQyxPQUFPLEVBQUUsTUFBTTtJQUNmQyxRQUFRLEVBQUUsTUFBTTtJQUNoQkMsU0FBUyxFQUFFLE1BQU07SUFDakJDLElBQUksRUFBRSxNQUFNO0VBQ2QsQ0FBQyxHQUFHO0lBQUVKLEVBQUUsRUFBRSxDQUFDO0lBQUVDLE9BQU8sRUFBRSxDQUFDO0lBQUVDLFFBQVEsRUFBRSxDQUFDO0lBQUVDLFNBQVMsRUFBRSxDQUFDO0lBQUVDLElBQUksRUFBRTtFQUFFLENBQUM7RUFDN0QsUUFBUUMsa0JBQWtCLEVBQUVDLFFBQVEsQ0FBQztJQUFFdkQsSUFBSSxFQUFFLFFBQVE7SUFBRUUsT0FBTyxFQUFFLE1BQU07RUFBQyxDQUFDLENBQUM7RUFDekU7RUFDQTtFQUNBO0VBQ0EsU0FBU3NELFNBQVMsRUFBRWhHLGNBQWMsR0FBR1Asb0JBQW9CLENBQUMsQ0FBQztFQUMzRDtFQUNBO0VBQ0EsUUFBUXdHLG9CQUFvQixHQUFHLEVBQUU7RUFDakM7RUFDQTtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0EsUUFBUUMsZUFBZSxFQUFFO0lBQ3ZCQyxTQUFTLEVBQUUxSCxhQUFhLEVBQUU7SUFDMUIySCxTQUFTLEVBQUUsTUFBTTtJQUNqQkMsVUFBVSxFQUFFLE1BQU07RUFDcEIsQ0FBQyxHQUFHLElBQUksR0FBRyxJQUFJO0VBQ2Y7RUFDQTtFQUNBO0VBQ0EsaUJBQWlCQyxrQkFBa0IsR0FBRyxJQUFJQyxHQUFHLENBQUMsR0FBRyxHQUFHLElBQUksQ0FBQyxDQUFDLENBQUM7RUFDM0Q7RUFDQTtFQUNBO0VBQ0EsaUJBQWlCQyxZQUFZLEdBQUcsSUFBSUQsR0FBRyxDQUFDdkosR0FBRyxDQUFDb0gsVUFBVSxDQUFDLENBQUMsQ0FBQztFQUN6RDtFQUNBO0VBQ0E7RUFDQTtFQUNBLFFBQVFxQyxlQUFlLEdBQUcsS0FBSztFQUMvQjtFQUNBO0VBQ0EsUUFBUUMsc0JBQXNCLEdBQUcsS0FBSztFQUN0QztFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0EsUUFBUUMscUJBQXFCLEdBQUcsS0FBSztFQUNyQztFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0EsUUFBUUMscUJBQXFCLEdBQUcsS0FBSztFQUNyQztFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0EsUUFBUUMsaUJBQWlCLEVBQUVoSyxpQkFBaUIsR0FBRyxJQUFJLEdBQUcsSUFBSTtFQUMxRDtFQUNBO0VBQ0E7RUFDQTtFQUNBLFFBQVFpSyxhQUFhLEVBQUU7SUFBRTFFLENBQUMsRUFBRSxNQUFNO0lBQUVDLENBQUMsRUFBRSxNQUFNO0VBQUMsQ0FBQyxHQUFHLElBQUksR0FBRyxJQUFJO0VBRTdEMEUsV0FBV0EsQ0FBQyxpQkFBaUJDLE9BQU8sRUFBRWxFLE9BQU8sRUFBRTtJQUM3Q3RILFFBQVEsQ0FBQyxJQUFJLENBQUM7SUFFZCxJQUFJLElBQUksQ0FBQ3dMLE9BQU8sQ0FBQzFELFlBQVksRUFBRTtNQUM3QixJQUFJLENBQUNxQixjQUFjLEdBQUcsSUFBSSxDQUFDckIsWUFBWSxDQUFDLENBQUM7TUFDekMsSUFBSSxDQUFDc0IsYUFBYSxHQUFHLElBQUksQ0FBQ3FDLFdBQVcsQ0FBQyxDQUFDO0lBQ3pDO0lBRUEsSUFBSSxDQUFDcEQsUUFBUSxHQUFHO01BQ2RkLE1BQU0sRUFBRWlFLE9BQU8sQ0FBQ2pFLE1BQU07TUFDdEJLLE1BQU0sRUFBRTRELE9BQU8sQ0FBQzVEO0lBQ2xCLENBQUM7SUFFRCxJQUFJLENBQUMwQixlQUFlLEdBQUdrQyxPQUFPLENBQUNqRSxNQUFNLENBQUNtRSxPQUFPLElBQUksRUFBRTtJQUNuRCxJQUFJLENBQUNyRSxZQUFZLEdBQUdtRSxPQUFPLENBQUNqRSxNQUFNLENBQUNvRSxJQUFJLElBQUksRUFBRTtJQUM3QyxJQUFJLENBQUNyQixrQkFBa0IsR0FBR2xELHNCQUFzQixDQUFDLElBQUksQ0FBQ0MsWUFBWSxDQUFDO0lBQ25FLElBQUksQ0FBQzBCLFNBQVMsR0FBRyxJQUFJbkYsU0FBUyxDQUFDLENBQUM7SUFDaEMsSUFBSSxDQUFDb0YsUUFBUSxHQUFHLElBQUkxRixRQUFRLENBQUMsQ0FBQztJQUM5QixJQUFJLENBQUMyRixhQUFhLEdBQUcsSUFBSXhGLGFBQWEsQ0FBQyxDQUFDO0lBQ3hDLElBQUksQ0FBQytGLFVBQVUsR0FBRzdILFVBQVUsQ0FDMUIsSUFBSSxDQUFDMEYsWUFBWSxFQUNqQixJQUFJLENBQUNpQyxlQUFlLEVBQ3BCLElBQUksQ0FBQ1AsU0FBUyxFQUNkLElBQUksQ0FBQ0MsUUFBUSxFQUNiLElBQUksQ0FBQ0MsYUFDUCxDQUFDO0lBQ0QsSUFBSSxDQUFDUSxTQUFTLEdBQUc5SCxVQUFVLENBQ3pCLElBQUksQ0FBQzBGLFlBQVksRUFDakIsSUFBSSxDQUFDaUMsZUFBZSxFQUNwQixJQUFJLENBQUNQLFNBQVMsRUFDZCxJQUFJLENBQUNDLFFBQVEsRUFDYixJQUFJLENBQUNDLGFBQ1AsQ0FBQztJQUVELElBQUksQ0FBQ2IsR0FBRyxHQUFHLElBQUluRyxTQUFTLENBQUM7TUFDdkIySixLQUFLLEVBQUdKLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ3FFLEtBQUssSUFBSSxPQUFPLEdBQUcsU0FBUyxJQUFLLEtBQUs7TUFDN0Q3QyxTQUFTLEVBQUUsSUFBSSxDQUFDQTtJQUNsQixDQUFDLENBQUM7O0lBRUY7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsTUFBTThDLGNBQWMsR0FBR0EsQ0FBQSxDQUFFLEVBQUUsSUFBSSxJQUFJQyxjQUFjLENBQUMsSUFBSSxDQUFDQyxRQUFRLENBQUM7SUFDaEUsSUFBSSxDQUFDekQsY0FBYyxHQUFHOUgsUUFBUSxDQUFDcUwsY0FBYyxFQUFFdEssaUJBQWlCLEVBQUU7TUFDaEV5SyxPQUFPLEVBQUUsSUFBSTtNQUNiQyxRQUFRLEVBQUU7SUFDWixDQUFDLENBQUM7O0lBRUY7SUFDQSxJQUFJLENBQUN6RCxXQUFXLEdBQUcsS0FBSzs7SUFFeEI7SUFDQSxJQUFJLENBQUMwRCxlQUFlLEdBQUdyTCxNQUFNLENBQUMsSUFBSSxDQUFDc0wsT0FBTyxFQUFFO01BQUVDLFVBQVUsRUFBRTtJQUFNLENBQUMsQ0FBQztJQUVsRSxJQUFJWixPQUFPLENBQUNqRSxNQUFNLENBQUNxRSxLQUFLLEVBQUU7TUFDeEJKLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQzhFLEVBQUUsQ0FBQyxRQUFRLEVBQUUsSUFBSSxDQUFDQyxZQUFZLENBQUM7TUFDOUNDLE9BQU8sQ0FBQ0YsRUFBRSxDQUFDLFNBQVMsRUFBRSxJQUFJLENBQUNHLFlBQVksQ0FBQztNQUV4QyxJQUFJLENBQUNuRCxzQkFBc0IsR0FBRyxNQUFNO1FBQ2xDbUMsT0FBTyxDQUFDakUsTUFBTSxDQUFDa0YsR0FBRyxDQUFDLFFBQVEsRUFBRSxJQUFJLENBQUNILFlBQVksQ0FBQztRQUMvQ0MsT0FBTyxDQUFDRSxHQUFHLENBQUMsU0FBUyxFQUFFLElBQUksQ0FBQ0QsWUFBWSxDQUFDO01BQzNDLENBQUM7SUFDSDtJQUVBLElBQUksQ0FBQzdELFFBQVEsR0FBR25ILEdBQUcsQ0FBQ2tMLFVBQVUsQ0FBQyxVQUFVLENBQUM7SUFDMUMsSUFBSSxDQUFDN0QsWUFBWSxHQUFHLElBQUluSCxZQUFZLENBQUMsQ0FBQ2lMLE1BQU0sRUFBRXpFLEtBQUssS0FDakQzRixVQUFVLENBQUNxSyxnQkFBZ0IsQ0FBQ0QsTUFBTSxFQUFFekUsS0FBSyxDQUMzQyxDQUFDO0lBQ0QsSUFBSSxDQUFDUyxRQUFRLENBQUNFLFlBQVksR0FBRyxJQUFJLENBQUNBLFlBQVk7SUFDOUMsSUFBSSxDQUFDQyxRQUFRLEdBQUczRixjQUFjLENBQUMsSUFBSSxDQUFDd0YsUUFBUSxFQUFFLElBQUksQ0FBQ0ksU0FBUyxDQUFDO0lBQzdELElBQUksQ0FBQ0osUUFBUSxDQUFDb0QsUUFBUSxHQUFHLElBQUksQ0FBQ3pELGNBQWM7SUFDNUMsSUFBSSxDQUFDSyxRQUFRLENBQUNrRSxpQkFBaUIsR0FBRyxJQUFJLENBQUNkLFFBQVE7SUFDL0MsSUFBSSxDQUFDcEQsUUFBUSxDQUFDbUUsZUFBZSxHQUFHLE1BQU07TUFDcEM7TUFDQTtNQUNBO01BQ0EsSUFBSSxJQUFJLENBQUN0RSxXQUFXLEVBQUU7UUFDcEI7TUFDRjtNQUVBLElBQUksSUFBSSxDQUFDRyxRQUFRLENBQUNvRSxRQUFRLEVBQUU7UUFDMUIsTUFBTUMsRUFBRSxHQUFHckQsV0FBVyxDQUFDQyxHQUFHLENBQUMsQ0FBQztRQUM1QixJQUFJLENBQUNqQixRQUFRLENBQUNvRSxRQUFRLENBQUNFLFFBQVEsQ0FBQyxJQUFJLENBQUMzRCxlQUFlLENBQUM7UUFDckQsSUFBSSxDQUFDWCxRQUFRLENBQUNvRSxRQUFRLENBQUNHLGVBQWUsQ0FBQyxJQUFJLENBQUM1RCxlQUFlLENBQUM7UUFDNUQsTUFBTVcsRUFBRSxHQUFHTixXQUFXLENBQUNDLEdBQUcsQ0FBQyxDQUFDLEdBQUdvRCxFQUFFO1FBQ2pDckssWUFBWSxDQUFDc0gsRUFBRSxDQUFDO1FBQ2hCLE1BQU1rRCxDQUFDLEdBQUdwTSxlQUFlLENBQUMsQ0FBQztRQUMzQixJQUFJLENBQUNpSixnQkFBZ0IsR0FBRztVQUFFQyxFQUFFO1VBQUUsR0FBR2tEO1FBQUUsQ0FBQztNQUN0QztJQUNGLENBQUM7O0lBRUQ7SUFDQTtJQUNBLElBQUksQ0FBQ3pFLFNBQVMsR0FBR3BHLFVBQVUsQ0FBQzhLLGVBQWUsQ0FDekMsSUFBSSxDQUFDekUsUUFBUSxFQUNiL0gsY0FBYyxFQUNkLElBQUksRUFDSixLQUFLLEVBQ0wsSUFBSSxFQUNKLElBQUksRUFDSkwsSUFBSTtJQUFFO0lBQ05BLElBQUk7SUFBRTtJQUNOQSxJQUFJO0lBQUU7SUFDTkEsSUFBSSxDQUFFO0lBQ1IsQ0FBQztJQUVELElBQUksWUFBWSxLQUFLLGFBQWEsRUFBRTtNQUNsQytCLFVBQVUsQ0FBQytLLGtCQUFrQixDQUFDO1FBQzVCQyxVQUFVLEVBQUUsQ0FBQztRQUNiO1FBQ0E7UUFDQUMsT0FBTyxFQUFFLFNBQVM7UUFDbEJDLG1CQUFtQixFQUFFO01BQ3ZCLENBQUMsQ0FBQztJQUNKO0VBQ0Y7RUFFQSxRQUFRaEIsWUFBWSxHQUFHQSxDQUFBLEtBQU07SUFDM0IsSUFBSSxDQUFDLElBQUksQ0FBQ2hCLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ3FFLEtBQUssRUFBRTtNQUM5QjtJQUNGOztJQUVBO0lBQ0E7SUFDQTtJQUNBLElBQUksSUFBSSxDQUFDWCxlQUFlLEVBQUU7TUFDeEIsSUFBSSxDQUFDd0MsZ0JBQWdCLENBQUMsQ0FBQztNQUN2QjtJQUNGOztJQUVBO0lBQ0EsSUFBSSxDQUFDakUsVUFBVSxHQUFHN0gsVUFBVSxDQUMxQixJQUFJLENBQUM2SCxVQUFVLENBQUNrRSxRQUFRLENBQUNDLE1BQU0sRUFDL0IsSUFBSSxDQUFDbkUsVUFBVSxDQUFDa0UsUUFBUSxDQUFDRSxLQUFLLEVBQzlCLElBQUksQ0FBQzdFLFNBQVMsRUFDZCxJQUFJLENBQUNDLFFBQVEsRUFDYixJQUFJLENBQUNDLGFBQ1AsQ0FBQztJQUNELElBQUksQ0FBQ1EsU0FBUyxHQUFHOUgsVUFBVSxDQUN6QixJQUFJLENBQUM4SCxTQUFTLENBQUNpRSxRQUFRLENBQUNDLE1BQU0sRUFDOUIsSUFBSSxDQUFDbEUsU0FBUyxDQUFDaUUsUUFBUSxDQUFDRSxLQUFLLEVBQzdCLElBQUksQ0FBQzdFLFNBQVMsRUFDZCxJQUFJLENBQUNDLFFBQVEsRUFDYixJQUFJLENBQUNDLGFBQ1AsQ0FBQztJQUNELElBQUksQ0FBQ2IsR0FBRyxDQUFDeUYsS0FBSyxDQUFDLENBQUM7SUFDaEI7SUFDQTtJQUNBO0lBQ0EsSUFBSSxDQUFDdkMsYUFBYSxHQUFHLElBQUk7RUFDM0IsQ0FBQzs7RUFFRDtFQUNBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQSxRQUFRZ0IsWUFBWSxHQUFHQSxDQUFBLEtBQU07SUFDM0IsTUFBTXdCLElBQUksR0FBRyxJQUFJLENBQUN0QyxPQUFPLENBQUNqRSxNQUFNLENBQUNtRSxPQUFPLElBQUksRUFBRTtJQUM5QyxNQUFNQyxJQUFJLEdBQUcsSUFBSSxDQUFDSCxPQUFPLENBQUNqRSxNQUFNLENBQUNvRSxJQUFJLElBQUksRUFBRTtJQUMzQztJQUNBO0lBQ0E7SUFDQSxJQUFJbUMsSUFBSSxLQUFLLElBQUksQ0FBQ3hFLGVBQWUsSUFBSXFDLElBQUksS0FBSyxJQUFJLENBQUN0RSxZQUFZLEVBQUU7SUFDakUsSUFBSSxDQUFDaUMsZUFBZSxHQUFHd0UsSUFBSTtJQUMzQixJQUFJLENBQUN6RyxZQUFZLEdBQUdzRSxJQUFJO0lBQ3hCLElBQUksQ0FBQ3JCLGtCQUFrQixHQUFHbEQsc0JBQXNCLENBQUMsSUFBSSxDQUFDQyxZQUFZLENBQUM7O0lBRW5FO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsSUFBSSxJQUFJLENBQUM0RCxlQUFlLElBQUksQ0FBQyxJQUFJLENBQUN4QyxRQUFRLElBQUksSUFBSSxDQUFDK0MsT0FBTyxDQUFDakUsTUFBTSxDQUFDcUUsS0FBSyxFQUFFO01BQ3ZFLElBQUksSUFBSSxDQUFDVixzQkFBc0IsRUFBRTtRQUMvQixJQUFJLENBQUNNLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ3dHLEtBQUssQ0FBQ2hJLHFCQUFxQixDQUFDO01BQ2xEO01BQ0EsSUFBSSxDQUFDaUksdUJBQXVCLENBQUMsQ0FBQztNQUM5QixJQUFJLENBQUM1QyxxQkFBcUIsR0FBRyxJQUFJO0lBQ25DOztJQUVBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxJQUFJLElBQUksQ0FBQzdCLFdBQVcsS0FBSyxJQUFJLEVBQUU7TUFDN0IsSUFBSSxDQUFDMEUsTUFBTSxDQUFDLElBQUksQ0FBQzFFLFdBQVcsQ0FBQztJQUMvQjtFQUNGLENBQUM7RUFFRDJFLGtCQUFrQixFQUFFLEdBQUcsR0FBRyxJQUFJLEdBQUdBLENBQUEsS0FBTSxDQUFDLENBQUM7RUFDekNDLGlCQUFpQixFQUFFLENBQUNDLE1BQWMsQ0FBUCxFQUFFQyxLQUFLLEVBQUUsR0FBRyxJQUFJLEdBQUdGLENBQUEsS0FBTSxDQUFDLENBQUM7RUFDdERqQyxlQUFlLEVBQUUsR0FBRyxHQUFHLElBQUksR0FBR0EsQ0FBQSxLQUFNLENBQUMsQ0FBQzs7RUFFdEM7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VvQyxvQkFBb0JBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUMzQixJQUFJLENBQUNDLEtBQUssQ0FBQyxDQUFDO0lBQ1osSUFBSSxDQUFDQyxZQUFZLENBQUMsQ0FBQztJQUNuQixJQUFJLENBQUNoRCxPQUFPLENBQUNqRSxNQUFNLENBQUN3RyxLQUFLO0lBQ3ZCO0lBQ0E7SUFDQTtJQUNBeEksc0JBQXNCLEdBQ3BCQyx5QkFBeUIsSUFDeEIsSUFBSSxDQUFDMEYsc0JBQXNCLEdBQUdwRixzQkFBc0IsR0FBRyxFQUFFLENBQUM7SUFBRztJQUM3RCxJQUFJLENBQUNtRixlQUFlLEdBQUcsRUFBRSxHQUFHLGFBQWEsQ0FBQztJQUFHO0lBQzlDLGFBQWE7SUFBRztJQUNoQixTQUFTO0lBQUc7SUFDWixXQUFXO0lBQUc7SUFDZCxTQUFTO0lBQUc7SUFDWixRQUFRLENBQUU7SUFDZCxDQUFDO0VBQ0g7O0VBRUE7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0V3RCxtQkFBbUJBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUMxQixJQUFJLENBQUNqRCxPQUFPLENBQUNqRSxNQUFNLENBQUN3RyxLQUFLLENBQ3ZCLENBQUMsSUFBSSxDQUFDOUMsZUFBZSxHQUFHakYsZ0JBQWdCLEdBQUcsRUFBRTtJQUFJO0lBQy9DLFNBQVM7SUFBRztJQUNaLFFBQVE7SUFBRztJQUNWLElBQUksQ0FBQ2tGLHNCQUFzQixHQUFHbkYscUJBQXFCLEdBQUcsRUFBRSxDQUFDO0lBQUc7SUFDNUQsSUFBSSxDQUFDa0YsZUFBZSxHQUFHLEVBQUUsR0FBRyxhQUFhLENBQUM7SUFBRztJQUM5QyxXQUFXLENBQUU7SUFDakIsQ0FBQztJQUNELElBQUksQ0FBQ3lELFdBQVcsQ0FBQyxDQUFDO0lBQ2xCLElBQUksSUFBSSxDQUFDekQsZUFBZSxFQUFFO01BQ3hCLElBQUksQ0FBQytDLHVCQUF1QixDQUFDLENBQUM7SUFDaEMsQ0FBQyxNQUFNO01BQ0wsSUFBSSxDQUFDVyxPQUFPLENBQUMsQ0FBQztJQUNoQjtJQUNBLElBQUksQ0FBQ0MsTUFBTSxDQUFDLENBQUM7SUFDYjtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxJQUFJLENBQUNwRCxPQUFPLENBQUNqRSxNQUFNLENBQUN3RyxLQUFLLENBQ3ZCLGFBQWEsSUFDVjlJLG9CQUFvQixDQUFDLENBQUMsR0FDbkJNLHNCQUFzQixHQUN0QkUscUJBQXFCLEdBQ3JCQyx3QkFBd0IsR0FDeEIsRUFBRSxDQUNWLENBQUM7RUFDSDtFQUVBcUcsUUFBUUEsQ0FBQSxFQUFHO0lBQ1QsSUFBSSxJQUFJLENBQUN2RCxXQUFXLElBQUksSUFBSSxDQUFDQyxRQUFRLEVBQUU7TUFDckM7SUFDRjtJQUNBO0lBQ0E7SUFDQTtJQUNBLElBQUksSUFBSSxDQUFDb0IsVUFBVSxLQUFLLElBQUksRUFBRTtNQUM1QmdGLFlBQVksQ0FBQyxJQUFJLENBQUNoRixVQUFVLENBQUM7TUFDN0IsSUFBSSxDQUFDQSxVQUFVLEdBQUcsSUFBSTtJQUN4Qjs7SUFFQTtJQUNBO0lBQ0E7SUFDQTtJQUNBL0ksb0JBQW9CLENBQUMsQ0FBQztJQUV0QixNQUFNZ08sV0FBVyxHQUFHbkYsV0FBVyxDQUFDQyxHQUFHLENBQUMsQ0FBQztJQUNyQyxNQUFNbUYsYUFBYSxHQUFHLElBQUksQ0FBQ3ZELE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ21FLE9BQU8sSUFBSSxFQUFFO0lBQ3ZELE1BQU1yRSxZQUFZLEdBQUcsSUFBSSxDQUFDbUUsT0FBTyxDQUFDakUsTUFBTSxDQUFDb0UsSUFBSSxJQUFJLEVBQUU7SUFFbkQsTUFBTXFELEtBQUssR0FBRyxJQUFJLENBQUNsRyxRQUFRLENBQUM7TUFDMUJVLFVBQVUsRUFBRSxJQUFJLENBQUNBLFVBQVU7TUFDM0JDLFNBQVMsRUFBRSxJQUFJLENBQUNBLFNBQVM7TUFDekJtQyxLQUFLLEVBQUUsSUFBSSxDQUFDSixPQUFPLENBQUNqRSxNQUFNLENBQUNxRSxLQUFLO01BQ2hDbUQsYUFBYTtNQUNiMUgsWUFBWTtNQUNaNEgsU0FBUyxFQUFFLElBQUksQ0FBQ2hFLGVBQWU7TUFDL0JFLHFCQUFxQixFQUFFLElBQUksQ0FBQ0E7SUFDOUIsQ0FBQyxDQUFDO0lBQ0YsTUFBTStELFVBQVUsR0FBR3ZGLFdBQVcsQ0FBQ0MsR0FBRyxDQUFDLENBQUMsR0FBR2tGLFdBQVc7O0lBRWxEO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxNQUFNSyxNQUFNLEdBQUdyTSxtQkFBbUIsQ0FBQyxDQUFDO0lBQ3BDLElBQ0VxTSxNQUFNLElBQ04sSUFBSSxDQUFDM0UsU0FBUyxDQUFDNEUsTUFBTTtJQUNyQjtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxJQUFJLENBQUM1RSxTQUFTLENBQUM0RSxNQUFNLENBQUNDLEdBQUcsSUFBSUYsTUFBTSxDQUFDRyxXQUFXLElBQy9DLElBQUksQ0FBQzlFLFNBQVMsQ0FBQzRFLE1BQU0sQ0FBQ0MsR0FBRyxJQUFJRixNQUFNLENBQUNJLGNBQWMsRUFDbEQ7TUFDQSxNQUFNO1FBQUVDLEtBQUs7UUFBRUYsV0FBVztRQUFFQztNQUFlLENBQUMsR0FBR0osTUFBTTtNQUNyRDtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBLElBQUksSUFBSSxDQUFDM0UsU0FBUyxDQUFDaUYsVUFBVSxFQUFFO1FBQzdCLElBQUluTCxZQUFZLENBQUMsSUFBSSxDQUFDa0csU0FBUyxDQUFDLEVBQUU7VUFDaEN6RyxtQkFBbUIsQ0FDakIsSUFBSSxDQUFDeUcsU0FBUyxFQUNkLElBQUksQ0FBQ2hCLFVBQVUsQ0FBQ2tHLE1BQU0sRUFDdEJKLFdBQVcsRUFDWEEsV0FBVyxHQUFHRSxLQUFLLEdBQUcsQ0FBQyxFQUN2QixPQUNGLENBQUM7UUFDSDtRQUNBN0ssV0FBVyxDQUFDLElBQUksQ0FBQzZGLFNBQVMsRUFBRSxDQUFDZ0YsS0FBSyxFQUFFRixXQUFXLEVBQUVDLGNBQWMsQ0FBQztNQUNsRSxDQUFDLE1BQU07TUFDTDtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQSxDQUFDLElBQUksQ0FBQy9FLFNBQVMsQ0FBQ21GLEtBQUssSUFDcEIsSUFBSSxDQUFDbkYsU0FBUyxDQUFDbUYsS0FBSyxDQUFDTixHQUFHLElBQUlDLFdBQVcsSUFDdEMsSUFBSSxDQUFDOUUsU0FBUyxDQUFDbUYsS0FBSyxDQUFDTixHQUFHLElBQUlFLGNBQWUsRUFDN0M7UUFDQSxJQUFJakwsWUFBWSxDQUFDLElBQUksQ0FBQ2tHLFNBQVMsQ0FBQyxFQUFFO1VBQ2hDekcsbUJBQW1CLENBQ2pCLElBQUksQ0FBQ3lHLFNBQVMsRUFDZCxJQUFJLENBQUNoQixVQUFVLENBQUNrRyxNQUFNLEVBQ3RCSixXQUFXLEVBQ1hBLFdBQVcsR0FBR0UsS0FBSyxHQUFHLENBQUMsRUFDdkIsT0FDRixDQUFDO1FBQ0g7UUFDQSxNQUFNSSxPQUFPLEdBQUcvSyx1QkFBdUIsQ0FDckMsSUFBSSxDQUFDMkYsU0FBUyxFQUNkLENBQUNnRixLQUFLLEVBQ05GLFdBQVcsRUFDWEMsY0FDRixDQUFDO1FBQ0Q7UUFDQTtRQUNBO1FBQ0E7UUFDQTtRQUNBLElBQUlLLE9BQU8sRUFBRSxLQUFLLE1BQU1DLEVBQUUsSUFBSSxJQUFJLENBQUMvRSxrQkFBa0IsRUFBRStFLEVBQUUsQ0FBQyxDQUFDO01BQzdEO0lBQ0Y7O0lBRUE7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxJQUFJQyxTQUFTLEdBQUcsS0FBSztJQUNyQixJQUFJQyxRQUFRLEdBQUcsS0FBSztJQUNwQixJQUFJLElBQUksQ0FBQzlFLGVBQWUsRUFBRTtNQUN4QjZFLFNBQVMsR0FBR3hMLFlBQVksQ0FBQyxJQUFJLENBQUNrRyxTQUFTLENBQUM7TUFDeEMsSUFBSXNGLFNBQVMsRUFBRTtRQUNiaE0scUJBQXFCLENBQUNrTCxLQUFLLENBQUNVLE1BQU0sRUFBRSxJQUFJLENBQUNsRixTQUFTLEVBQUUsSUFBSSxDQUFDekIsU0FBUyxDQUFDO01BQ3JFO01BQ0E7TUFDQTtNQUNBZ0gsUUFBUSxHQUFHbE0sb0JBQW9CLENBQzdCbUwsS0FBSyxDQUFDVSxNQUFNLEVBQ1osSUFBSSxDQUFDakYsb0JBQW9CLEVBQ3pCLElBQUksQ0FBQzFCLFNBQ1AsQ0FBQztNQUNEO01BQ0E7TUFDQTtNQUNBLElBQUksSUFBSSxDQUFDMkIsZUFBZSxFQUFFO1FBQ3hCLE1BQU1zRixFQUFFLEdBQUcsSUFBSSxDQUFDdEYsZUFBZTtRQUMvQixNQUFNdUYsVUFBVSxHQUFHak4sd0JBQXdCLENBQ3pDZ00sS0FBSyxDQUFDVSxNQUFNLEVBQ1osSUFBSSxDQUFDM0csU0FBUyxFQUNkaUgsRUFBRSxDQUFDckYsU0FBUyxFQUNacUYsRUFBRSxDQUFDcEYsU0FBUyxFQUNab0YsRUFBRSxDQUFDbkYsVUFDTCxDQUFDO1FBQ0RrRixRQUFRLEdBQUdBLFFBQVEsSUFBSUUsVUFBVTtNQUNuQztJQUNGOztJQUVBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxJQUNFbE4sY0FBYyxDQUFDLENBQUMsSUFDaEIrTSxTQUFTLElBQ1RDLFFBQVEsSUFDUixJQUFJLENBQUM1RSxxQkFBcUIsRUFDMUI7TUFDQTZELEtBQUssQ0FBQ1UsTUFBTSxDQUFDUSxNQUFNLEdBQUc7UUFDcEJ0SixDQUFDLEVBQUUsQ0FBQztRQUNKQyxDQUFDLEVBQUUsQ0FBQztRQUNKK0csS0FBSyxFQUFFb0IsS0FBSyxDQUFDVSxNQUFNLENBQUM5QixLQUFLO1FBQ3pCRCxNQUFNLEVBQUVxQixLQUFLLENBQUNVLE1BQU0sQ0FBQy9CO01BQ3ZCLENBQUM7SUFDSDs7SUFFQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBLElBQUl3QyxTQUFTLEdBQUcsSUFBSSxDQUFDM0csVUFBVTtJQUMvQixJQUFJLElBQUksQ0FBQ3lCLGVBQWUsRUFBRTtNQUN4QmtGLFNBQVMsR0FBRztRQUFFLEdBQUcsSUFBSSxDQUFDM0csVUFBVTtRQUFFNEcsTUFBTSxFQUFFM0o7TUFBeUIsQ0FBQztJQUN0RTtJQUVBLE1BQU00SixLQUFLLEdBQUcxRyxXQUFXLENBQUNDLEdBQUcsQ0FBQyxDQUFDO0lBQy9CLE1BQU0wRyxJQUFJLEdBQUcsSUFBSSxDQUFDbEksR0FBRyxDQUFDNkYsTUFBTSxDQUMxQmtDLFNBQVMsRUFDVG5CLEtBQUssRUFDTCxJQUFJLENBQUMvRCxlQUFlO0lBQ3BCO0lBQ0E7SUFDQTtJQUNBO0lBQ0FqRyxxQkFDRixDQUFDO0lBQ0QsTUFBTXVMLE1BQU0sR0FBRzVHLFdBQVcsQ0FBQ0MsR0FBRyxDQUFDLENBQUMsR0FBR3lHLEtBQUs7SUFDeEM7SUFDQSxJQUFJLENBQUM1RyxTQUFTLEdBQUcsSUFBSSxDQUFDRCxVQUFVO0lBQ2hDLElBQUksQ0FBQ0EsVUFBVSxHQUFHd0YsS0FBSzs7SUFFdkI7SUFDQTtJQUNBO0lBQ0EsSUFBSUYsV0FBVyxHQUFHLElBQUksQ0FBQ3BGLGlCQUFpQixHQUFHLENBQUMsR0FBRyxFQUFFLEdBQUcsSUFBSSxFQUFFO01BQ3hELElBQUksQ0FBQzhHLFVBQVUsQ0FBQyxDQUFDO01BQ2pCLElBQUksQ0FBQzlHLGlCQUFpQixHQUFHb0YsV0FBVztJQUN0QztJQUVBLE1BQU0yQixRQUFRLEVBQUU1TyxVQUFVLENBQUMsVUFBVSxDQUFDLEdBQUcsRUFBRTtJQUMzQyxLQUFLLE1BQU02TyxLQUFLLElBQUlKLElBQUksRUFBRTtNQUN4QixJQUFJSSxLQUFLLENBQUMxSixJQUFJLEtBQUssZUFBZSxFQUFFO1FBQ2xDeUosUUFBUSxDQUFDRSxJQUFJLENBQUM7VUFDWkMsYUFBYSxFQUFFNUIsS0FBSyxDQUFDVSxNQUFNLENBQUMvQixNQUFNO1VBQ2xDa0QsZUFBZSxFQUFFN0IsS0FBSyxDQUFDdEIsUUFBUSxDQUFDQyxNQUFNO1VBQ3RDUyxNQUFNLEVBQUVzQyxLQUFLLENBQUN0QztRQUNoQixDQUFDLENBQUM7UUFDRixJQUFJMUwsc0JBQXNCLENBQUMsQ0FBQyxJQUFJZ08sS0FBSyxDQUFDSSxLQUFLLEVBQUU7VUFDM0MsTUFBTUMsS0FBSyxHQUFHdlAsR0FBRyxDQUFDd1AsbUJBQW1CLENBQ25DLElBQUksQ0FBQ3JJLFFBQVEsRUFDYitILEtBQUssQ0FBQ0ksS0FBSyxDQUFDRyxRQUNkLENBQUM7VUFDRGpRLGVBQWUsQ0FDYiwwQkFBMEIwUCxLQUFLLENBQUN0QyxNQUFNLFVBQVVzQyxLQUFLLENBQUNJLEtBQUssQ0FBQ0csUUFBUSxJQUFJLEdBQ3RFLFlBQVlQLEtBQUssQ0FBQ0ksS0FBSyxDQUFDSSxRQUFRLEtBQUssR0FDckMsWUFBWVIsS0FBSyxDQUFDSSxLQUFLLENBQUNLLFFBQVEsS0FBSyxHQUNyQyxjQUFjSixLQUFLLENBQUNLLE1BQU0sR0FBR0wsS0FBSyxDQUFDTSxJQUFJLENBQUMsS0FBSyxDQUFDLEdBQUcsMkJBQTJCLEVBQUUsRUFDaEY7WUFBRUMsS0FBSyxFQUFFO1VBQU8sQ0FDbEIsQ0FBQztRQUNIO01BQ0Y7SUFDRjtJQUVBLE1BQU1DLFNBQVMsR0FBRzVILFdBQVcsQ0FBQ0MsR0FBRyxDQUFDLENBQUM7SUFDbkMsTUFBTTRILFNBQVMsR0FBR3JQLFFBQVEsQ0FBQ21PLElBQUksQ0FBQztJQUNoQyxNQUFNbUIsVUFBVSxHQUFHOUgsV0FBVyxDQUFDQyxHQUFHLENBQUMsQ0FBQyxHQUFHMkgsU0FBUztJQUNoRCxNQUFNRyxPQUFPLEdBQUdGLFNBQVMsQ0FBQ0osTUFBTSxHQUFHLENBQUM7SUFDcEMsSUFBSSxJQUFJLENBQUNuRyxlQUFlLElBQUl5RyxPQUFPLEVBQUU7TUFDbkM7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQTtNQUNBO01BQ0E7TUFDQSxJQUFJLElBQUksQ0FBQ3RHLHFCQUFxQixFQUFFO1FBQzlCLElBQUksQ0FBQ0EscUJBQXFCLEdBQUcsS0FBSztRQUNsQ29HLFNBQVMsQ0FBQ0csT0FBTyxDQUFDeEsscUJBQXFCLENBQUM7TUFDMUMsQ0FBQyxNQUFNO1FBQ0xxSyxTQUFTLENBQUNHLE9BQU8sQ0FBQzVLLGlCQUFpQixDQUFDO01BQ3RDO01BQ0F5SyxTQUFTLENBQUNiLElBQUksQ0FBQyxJQUFJLENBQUNyRyxrQkFBa0IsQ0FBQztJQUN6Qzs7SUFFQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBLE1BQU1zSCxJQUFJLEdBQUcsSUFBSSxDQUFDdkcsaUJBQWlCO0lBQ25DLE1BQU13RyxJQUFJLEdBQUdELElBQUksS0FBSyxJQUFJLEdBQUcxUCxTQUFTLENBQUM0UCxHQUFHLENBQUNGLElBQUksQ0FBQ0csSUFBSSxDQUFDLEdBQUdDLFNBQVM7SUFDakUsTUFBTXJGLE1BQU0sR0FDVmlGLElBQUksS0FBSyxJQUFJLElBQUlDLElBQUksS0FBS0csU0FBUyxHQUMvQjtNQUFFcEwsQ0FBQyxFQUFFaUwsSUFBSSxDQUFDakwsQ0FBQyxHQUFHZ0wsSUFBSSxDQUFDSyxTQUFTO01BQUVwTCxDQUFDLEVBQUVnTCxJQUFJLENBQUNoTCxDQUFDLEdBQUcrSyxJQUFJLENBQUNNO0lBQVUsQ0FBQyxHQUMxRCxJQUFJO0lBQ1YsTUFBTUMsTUFBTSxHQUFHLElBQUksQ0FBQzdHLGFBQWE7O0lBRWpDO0lBQ0E7SUFDQSxNQUFNOEcsV0FBVyxHQUNmekYsTUFBTSxLQUFLLElBQUksS0FDZHdGLE1BQU0sS0FBSyxJQUFJLElBQUlBLE1BQU0sQ0FBQ3ZMLENBQUMsS0FBSytGLE1BQU0sQ0FBQy9GLENBQUMsSUFBSXVMLE1BQU0sQ0FBQ3RMLENBQUMsS0FBSzhGLE1BQU0sQ0FBQzlGLENBQUMsQ0FBQztJQUNyRSxJQUFJNkssT0FBTyxJQUFJVSxXQUFXLElBQUt6RixNQUFNLEtBQUssSUFBSSxJQUFJd0YsTUFBTSxLQUFLLElBQUssRUFBRTtNQUNsRTtNQUNBO01BQ0E7TUFDQTtNQUNBLElBQUlBLE1BQU0sS0FBSyxJQUFJLElBQUksQ0FBQyxJQUFJLENBQUNsSCxlQUFlLElBQUl5RyxPQUFPLEVBQUU7UUFDdkQsTUFBTVcsR0FBRyxHQUFHbEMsU0FBUyxDQUFDQyxNQUFNLENBQUN4SixDQUFDLEdBQUd1TCxNQUFNLENBQUN2TCxDQUFDO1FBQ3pDLE1BQU0wTCxHQUFHLEdBQUduQyxTQUFTLENBQUNDLE1BQU0sQ0FBQ3ZKLENBQUMsR0FBR3NMLE1BQU0sQ0FBQ3RMLENBQUM7UUFDekMsSUFBSXdMLEdBQUcsS0FBSyxDQUFDLElBQUlDLEdBQUcsS0FBSyxDQUFDLEVBQUU7VUFDMUJkLFNBQVMsQ0FBQ0csT0FBTyxDQUFDO1lBQUUzSyxJQUFJLEVBQUUsUUFBUTtZQUFFRSxPQUFPLEVBQUU3QixVQUFVLENBQUNnTixHQUFHLEVBQUVDLEdBQUc7VUFBRSxDQUFDLENBQUM7UUFDdEU7TUFDRjtNQUVBLElBQUkzRixNQUFNLEtBQUssSUFBSSxFQUFFO1FBQ25CLElBQUksSUFBSSxDQUFDMUIsZUFBZSxFQUFFO1VBQ3hCO1VBQ0E7VUFDQSxNQUFNb0UsR0FBRyxHQUFHa0QsSUFBSSxDQUFDQyxHQUFHLENBQUNELElBQUksQ0FBQ0UsR0FBRyxDQUFDOUYsTUFBTSxDQUFDOUYsQ0FBQyxHQUFHLENBQUMsRUFBRSxDQUFDLENBQUMsRUFBRVEsWUFBWSxDQUFDO1VBQzdELE1BQU1xTCxHQUFHLEdBQUdILElBQUksQ0FBQ0MsR0FBRyxDQUFDRCxJQUFJLENBQUNFLEdBQUcsQ0FBQzlGLE1BQU0sQ0FBQy9GLENBQUMsR0FBRyxDQUFDLEVBQUUsQ0FBQyxDQUFDLEVBQUVtSSxhQUFhLENBQUM7VUFDOUR5QyxTQUFTLENBQUNiLElBQUksQ0FBQztZQUFFM0osSUFBSSxFQUFFLFFBQVE7WUFBRUUsT0FBTyxFQUFFNUIsY0FBYyxDQUFDK0osR0FBRyxFQUFFcUQsR0FBRztVQUFFLENBQUMsQ0FBQztRQUN2RSxDQUFDLE1BQU07VUFDTDtVQUNBO1VBQ0E7VUFDQSxNQUFNQyxJQUFJLEdBQ1IsQ0FBQ2pCLE9BQU8sSUFBSVMsTUFBTSxLQUFLLElBQUksR0FDdkJBLE1BQU0sR0FDTjtZQUFFdkwsQ0FBQyxFQUFFb0ksS0FBSyxDQUFDb0IsTUFBTSxDQUFDeEosQ0FBQztZQUFFQyxDQUFDLEVBQUVtSSxLQUFLLENBQUNvQixNQUFNLENBQUN2SjtVQUFFLENBQUM7VUFDOUMsTUFBTStMLEVBQUUsR0FBR2pHLE1BQU0sQ0FBQy9GLENBQUMsR0FBRytMLElBQUksQ0FBQy9MLENBQUM7VUFDNUIsTUFBTWlNLEVBQUUsR0FBR2xHLE1BQU0sQ0FBQzlGLENBQUMsR0FBRzhMLElBQUksQ0FBQzlMLENBQUM7VUFDNUIsSUFBSStMLEVBQUUsS0FBSyxDQUFDLElBQUlDLEVBQUUsS0FBSyxDQUFDLEVBQUU7WUFDeEJyQixTQUFTLENBQUNiLElBQUksQ0FBQztjQUFFM0osSUFBSSxFQUFFLFFBQVE7Y0FBRUUsT0FBTyxFQUFFN0IsVUFBVSxDQUFDdU4sRUFBRSxFQUFFQyxFQUFFO1lBQUUsQ0FBQyxDQUFDO1VBQ2pFO1FBQ0Y7UUFDQSxJQUFJLENBQUN2SCxhQUFhLEdBQUdxQixNQUFNO01BQzdCLENBQUMsTUFBTTtRQUNMO1FBQ0E7UUFDQTtRQUNBO1FBQ0E7UUFDQTtRQUNBO1FBQ0EsSUFBSXdGLE1BQU0sS0FBSyxJQUFJLElBQUksQ0FBQyxJQUFJLENBQUNsSCxlQUFlLElBQUksQ0FBQ3lHLE9BQU8sRUFBRTtVQUN4RCxNQUFNb0IsR0FBRyxHQUFHOUQsS0FBSyxDQUFDb0IsTUFBTSxDQUFDeEosQ0FBQyxHQUFHdUwsTUFBTSxDQUFDdkwsQ0FBQztVQUNyQyxNQUFNbU0sR0FBRyxHQUFHL0QsS0FBSyxDQUFDb0IsTUFBTSxDQUFDdkosQ0FBQyxHQUFHc0wsTUFBTSxDQUFDdEwsQ0FBQztVQUNyQyxJQUFJaU0sR0FBRyxLQUFLLENBQUMsSUFBSUMsR0FBRyxLQUFLLENBQUMsRUFBRTtZQUMxQnZCLFNBQVMsQ0FBQ2IsSUFBSSxDQUFDO2NBQUUzSixJQUFJLEVBQUUsUUFBUTtjQUFFRSxPQUFPLEVBQUU3QixVQUFVLENBQUN5TixHQUFHLEVBQUVDLEdBQUc7WUFBRSxDQUFDLENBQUM7VUFDbkU7UUFDRjtRQUNBLElBQUksQ0FBQ3pILGFBQWEsR0FBRyxJQUFJO01BQzNCO0lBQ0Y7SUFFQSxNQUFNMEgsTUFBTSxHQUFHckosV0FBVyxDQUFDQyxHQUFHLENBQUMsQ0FBQztJQUNoQ3pFLG1CQUFtQixDQUNqQixJQUFJLENBQUNrRCxRQUFRLEVBQ2JtSixTQUFTLEVBQ1QsSUFBSSxDQUFDdkcsZUFBZSxJQUFJLENBQUNqRyxxQkFDM0IsQ0FBQztJQUNELE1BQU1pTyxPQUFPLEdBQUd0SixXQUFXLENBQUNDLEdBQUcsQ0FBQyxDQUFDLEdBQUdvSixNQUFNOztJQUUxQztJQUNBO0lBQ0E7SUFDQTtJQUNBLElBQUksQ0FBQzdILHFCQUFxQixHQUFHMkUsU0FBUyxJQUFJQyxRQUFROztJQUVsRDtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQSxJQUFJZixLQUFLLENBQUNrRSxrQkFBa0IsRUFBRTtNQUM1QixJQUFJLENBQUNySixVQUFVLEdBQUdFLFVBQVUsQ0FDMUIsTUFBTSxJQUFJLENBQUNnQyxRQUFRLENBQUMsQ0FBQyxFQUNyQnhLLGlCQUFpQixJQUFJLENBQ3ZCLENBQUM7SUFDSDtJQUVBLE1BQU00UixNQUFNLEdBQUcxUSxhQUFhLENBQUMsQ0FBQztJQUM5QixNQUFNMlEsUUFBUSxHQUFHNVEsZUFBZSxDQUFDLENBQUM7SUFDbEMsTUFBTTZRLEVBQUUsR0FBRyxJQUFJLENBQUNySixnQkFBZ0I7SUFDaEM7SUFDQXBILG9CQUFvQixDQUFDLENBQUM7SUFDdEIsSUFBSSxDQUFDb0gsZ0JBQWdCLEdBQUc7TUFDdEJDLEVBQUUsRUFBRSxDQUFDO01BQ0xDLE9BQU8sRUFBRSxDQUFDO01BQ1ZDLFFBQVEsRUFBRSxDQUFDO01BQ1hDLFNBQVMsRUFBRSxDQUFDO01BQ1pDLElBQUksRUFBRTtJQUNSLENBQUM7SUFDRCxJQUFJLENBQUNtQixPQUFPLENBQUN2RCxPQUFPLEdBQUc7TUFDckJxTCxVQUFVLEVBQUUzSixXQUFXLENBQUNDLEdBQUcsQ0FBQyxDQUFDLEdBQUdrRixXQUFXO01BQzNDeUUsTUFBTSxFQUFFO1FBQ056SyxRQUFRLEVBQUVvRyxVQUFVO1FBQ3BCb0IsSUFBSSxFQUFFQyxNQUFNO1FBQ1pwTyxRQUFRLEVBQUVzUCxVQUFVO1FBQ3BCMUQsS0FBSyxFQUFFa0YsT0FBTztRQUNkTyxPQUFPLEVBQUVsRCxJQUFJLENBQUNjLE1BQU07UUFDcEJxQyxJQUFJLEVBQUVOLE1BQU07UUFDWk8sTUFBTSxFQUFFTixRQUFRO1FBQ2hCTyxXQUFXLEVBQUVOLEVBQUUsQ0FBQ25KLE9BQU87UUFDdkIwSixZQUFZLEVBQUVQLEVBQUUsQ0FBQ2xKLFFBQVE7UUFDekIwSixhQUFhLEVBQUVSLEVBQUUsQ0FBQ2pKLFNBQVM7UUFDM0IwSixRQUFRLEVBQUVULEVBQUUsQ0FBQ2hKO01BQ2YsQ0FBQztNQUNEb0c7SUFDRixDQUFDLENBQUM7RUFDSjtFQUVBbEMsS0FBS0EsQ0FBQSxDQUFFLEVBQUUsSUFBSSxDQUFDO0lBQ1o7SUFDQTtJQUNBak0sVUFBVSxDQUFDeVIsdUJBQXVCLENBQUMsQ0FBQztJQUNwQyxJQUFJLENBQUNoSSxRQUFRLENBQUMsQ0FBQztJQUVmLElBQUksQ0FBQ3RELFFBQVEsR0FBRyxJQUFJO0VBQ3RCO0VBRUFtRyxNQUFNQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDYixJQUFJLENBQUNuRyxRQUFRLEdBQUcsS0FBSztJQUNyQixJQUFJLENBQUNzRCxRQUFRLENBQUMsQ0FBQztFQUNqQjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0U0QyxPQUFPQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDZCxJQUFJLENBQUNuRixVQUFVLEdBQUc3SCxVQUFVLENBQzFCLElBQUksQ0FBQzZILFVBQVUsQ0FBQ2tFLFFBQVEsQ0FBQ0MsTUFBTSxFQUMvQixJQUFJLENBQUNuRSxVQUFVLENBQUNrRSxRQUFRLENBQUNFLEtBQUssRUFDOUIsSUFBSSxDQUFDN0UsU0FBUyxFQUNkLElBQUksQ0FBQ0MsUUFBUSxFQUNiLElBQUksQ0FBQ0MsYUFDUCxDQUFDO0lBQ0QsSUFBSSxDQUFDUSxTQUFTLEdBQUc5SCxVQUFVLENBQ3pCLElBQUksQ0FBQzhILFNBQVMsQ0FBQ2lFLFFBQVEsQ0FBQ0MsTUFBTSxFQUM5QixJQUFJLENBQUNsRSxTQUFTLENBQUNpRSxRQUFRLENBQUNFLEtBQUssRUFDN0IsSUFBSSxDQUFDN0UsU0FBUyxFQUNkLElBQUksQ0FBQ0MsUUFBUSxFQUNiLElBQUksQ0FBQ0MsYUFDUCxDQUFDO0lBQ0QsSUFBSSxDQUFDYixHQUFHLENBQUN5RixLQUFLLENBQUMsQ0FBQztJQUNoQjtJQUNBO0lBQ0E7SUFDQSxJQUFJLENBQUN2QyxhQUFhLEdBQUcsSUFBSTtFQUMzQjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0UwSSxXQUFXQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDbEIsSUFBSSxDQUFDLElBQUksQ0FBQ3hJLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ3FFLEtBQUssSUFBSSxJQUFJLENBQUNwRCxXQUFXLElBQUksSUFBSSxDQUFDQyxRQUFRLEVBQUU7SUFDckUsSUFBSSxDQUFDK0MsT0FBTyxDQUFDakUsTUFBTSxDQUFDd0csS0FBSyxDQUFDcEksWUFBWSxHQUFHUCxXQUFXLENBQUM7SUFDckQsSUFBSSxJQUFJLENBQUM2RixlQUFlLEVBQUU7TUFDeEIsSUFBSSxDQUFDK0MsdUJBQXVCLENBQUMsQ0FBQztJQUNoQyxDQUFDLE1BQU07TUFDTCxJQUFJLENBQUNXLE9BQU8sQ0FBQyxDQUFDO01BQ2Q7TUFDQTtNQUNBO01BQ0EsSUFBSSxDQUFDeEQscUJBQXFCLEdBQUcsSUFBSTtJQUNuQztJQUNBLElBQUksQ0FBQ1ksUUFBUSxDQUFDLENBQUM7RUFDakI7O0VBRUE7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRWtJLG1CQUFtQkEsQ0FBQSxDQUFFLEVBQUUsSUFBSSxDQUFDO0lBQzFCLElBQUksQ0FBQzlJLHFCQUFxQixHQUFHLElBQUk7RUFDbkM7O0VBRUE7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRStJLGtCQUFrQkEsQ0FBQ0MsTUFBTSxFQUFFLE9BQU8sRUFBRUMsYUFBYSxHQUFHLEtBQUssQ0FBQyxFQUFFLElBQUksQ0FBQztJQUMvRCxJQUFJLElBQUksQ0FBQ25KLGVBQWUsS0FBS2tKLE1BQU0sRUFBRTtJQUNyQyxJQUFJLENBQUNsSixlQUFlLEdBQUdrSixNQUFNO0lBQzdCLElBQUksQ0FBQ2pKLHNCQUFzQixHQUFHaUosTUFBTSxJQUFJQyxhQUFhO0lBQ3JELElBQUlELE1BQU0sRUFBRTtNQUNWLElBQUksQ0FBQ25HLHVCQUF1QixDQUFDLENBQUM7SUFDaEMsQ0FBQyxNQUFNO01BQ0wsSUFBSSxDQUFDVyxPQUFPLENBQUMsQ0FBQztJQUNoQjtFQUNGO0VBRUEsSUFBSTBGLGlCQUFpQkEsQ0FBQSxDQUFFLEVBQUUsT0FBTyxDQUFDO0lBQy9CLE9BQU8sSUFBSSxDQUFDcEosZUFBZTtFQUM3Qjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRXFKLHFCQUFxQixHQUFHQSxDQUFDQyxnQkFBZ0IsR0FBRyxLQUFLLENBQUMsRUFBRSxJQUFJLElBQUk7SUFDMUQsSUFBSSxDQUFDLElBQUksQ0FBQy9JLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ3FFLEtBQUssRUFBRTtJQUNoQztJQUNBO0lBQ0E7SUFDQSxJQUFJLElBQUksQ0FBQ25ELFFBQVEsRUFBRTtJQUNuQjtJQUNBO0lBQ0E7SUFDQTtJQUNBLElBQUl4RCxvQkFBb0IsQ0FBQyxDQUFDLEVBQUU7TUFDMUIsSUFBSSxDQUFDdUcsT0FBTyxDQUFDakUsTUFBTSxDQUFDd0csS0FBSyxDQUN2QnhJLHNCQUFzQixHQUNwQkUscUJBQXFCLEdBQ3JCQyx3QkFDSixDQUFDO0lBQ0g7SUFDQSxJQUFJLENBQUMsSUFBSSxDQUFDdUYsZUFBZSxFQUFFO0lBQzNCO0lBQ0EsSUFBSSxJQUFJLENBQUNDLHNCQUFzQixFQUFFO01BQy9CLElBQUksQ0FBQ00sT0FBTyxDQUFDakUsTUFBTSxDQUFDd0csS0FBSyxDQUFDaEkscUJBQXFCLENBQUM7SUFDbEQ7SUFDQTtJQUNBO0lBQ0EsSUFBSXdPLGdCQUFnQixFQUFFO01BQ3BCLElBQUksQ0FBQzlHLGdCQUFnQixDQUFDLENBQUM7SUFDekI7RUFDRixDQUFDOztFQUVEO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRStHLGlCQUFpQkEsQ0FBQSxDQUFFLEVBQUUsSUFBSSxDQUFDO0lBQ3hCLElBQUksQ0FBQ2hNLFdBQVcsR0FBRyxJQUFJO0lBQ3ZCO0lBQ0E7SUFDQSxJQUFJLENBQUNGLGNBQWMsQ0FBQ0MsTUFBTSxHQUFHLENBQUM7SUFDOUI7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBLE1BQU1iLEtBQUssR0FBRyxJQUFJLENBQUM4RCxPQUFPLENBQUM5RCxLQUFLLElBQUlGLE1BQU0sQ0FBQ0csVUFBVSxHQUFHO01BQ3REOE0sS0FBSyxDQUFDLEVBQUUsT0FBTztNQUNmQyxVQUFVLENBQUMsRUFBRSxDQUFDQyxDQUFDLEVBQUUsT0FBTyxFQUFFLEdBQUcsSUFBSTtJQUNuQyxDQUFDO0lBQ0QsSUFBSSxDQUFDQyxVQUFVLENBQUMsQ0FBQztJQUNqQixJQUFJbE4sS0FBSyxDQUFDa0UsS0FBSyxJQUFJbEUsS0FBSyxDQUFDK00sS0FBSyxJQUFJL00sS0FBSyxDQUFDZ04sVUFBVSxFQUFFO01BQ2xEaE4sS0FBSyxDQUFDZ04sVUFBVSxDQUFDLEtBQUssQ0FBQztJQUN6QjtFQUNGOztFQUVBO0VBQ0FFLFVBQVVBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUNqQkEsVUFBVSxDQUFDLElBQUksQ0FBQ3BKLE9BQU8sQ0FBQzlELEtBQUssQ0FBQztFQUNoQzs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFLFFBQVErRixnQkFBZ0JBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUMvQixJQUFJLENBQUNqQyxPQUFPLENBQUNqRSxNQUFNLENBQUN3RyxLQUFLLENBQ3ZCL0gsZ0JBQWdCLEdBQ2RMLFlBQVksR0FDWlAsV0FBVyxJQUNWLElBQUksQ0FBQzhGLHNCQUFzQixHQUFHbkYscUJBQXFCLEdBQUcsRUFBRSxDQUM3RCxDQUFDO0lBQ0QsSUFBSSxDQUFDaUksdUJBQXVCLENBQUMsQ0FBQztFQUNoQzs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRSxRQUFRQSx1QkFBdUJBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUN0QyxNQUFNckMsSUFBSSxHQUFHLElBQUksQ0FBQ3RFLFlBQVk7SUFDOUIsTUFBTXlHLElBQUksR0FBRyxJQUFJLENBQUN4RSxlQUFlO0lBQ2pDLE1BQU11TCxLQUFLLEdBQUdBLENBQUEsQ0FBRSxFQUFFalQsS0FBSyxLQUFLO01BQzFCOE4sTUFBTSxFQUFFbE0sWUFBWSxDQUNsQnNLLElBQUksRUFDSm5DLElBQUksRUFDSixJQUFJLENBQUM1QyxTQUFTLEVBQ2QsSUFBSSxDQUFDQyxRQUFRLEVBQ2IsSUFBSSxDQUFDQyxhQUNQLENBQUM7TUFDRHlFLFFBQVEsRUFBRTtRQUFFRSxLQUFLLEVBQUVFLElBQUk7UUFBRUgsTUFBTSxFQUFFaEMsSUFBSSxHQUFHO01BQUUsQ0FBQztNQUMzQ3lFLE1BQU0sRUFBRTtRQUFFeEosQ0FBQyxFQUFFLENBQUM7UUFBRUMsQ0FBQyxFQUFFLENBQUM7UUFBRUMsT0FBTyxFQUFFO01BQUs7SUFDdEMsQ0FBQyxDQUFDO0lBQ0YsSUFBSSxDQUFDMEMsVUFBVSxHQUFHcUwsS0FBSyxDQUFDLENBQUM7SUFDekIsSUFBSSxDQUFDcEwsU0FBUyxHQUFHb0wsS0FBSyxDQUFDLENBQUM7SUFDeEIsSUFBSSxDQUFDek0sR0FBRyxDQUFDeUYsS0FBSyxDQUFDLENBQUM7SUFDaEI7SUFDQTtJQUNBO0lBQ0EsSUFBSSxDQUFDdkMsYUFBYSxHQUFHLElBQUk7SUFDekI7SUFDQTtJQUNBLElBQUksQ0FBQ0gscUJBQXFCLEdBQUcsSUFBSTtFQUNuQzs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0VBQ0UySixvQkFBb0JBLENBQUEsQ0FBRSxFQUFFLE1BQU0sQ0FBQztJQUM3QixJQUFJLENBQUN4USxZQUFZLENBQUMsSUFBSSxDQUFDa0csU0FBUyxDQUFDLEVBQUUsT0FBTyxFQUFFO0lBQzVDLE1BQU11SyxJQUFJLEdBQUcxUSxlQUFlLENBQUMsSUFBSSxDQUFDbUcsU0FBUyxFQUFFLElBQUksQ0FBQ2hCLFVBQVUsQ0FBQ2tHLE1BQU0sQ0FBQztJQUNwRSxJQUFJcUYsSUFBSSxFQUFFO01BQ1I7TUFDQTtNQUNBLEtBQUsxTyxZQUFZLENBQUMwTyxJQUFJLENBQUMsQ0FBQ0MsSUFBSSxDQUFDQyxHQUFHLElBQUk7UUFDbEMsSUFBSUEsR0FBRyxFQUFFLElBQUksQ0FBQ3pKLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ3dHLEtBQUssQ0FBQ2tILEdBQUcsQ0FBQztNQUN6QyxDQUFDLENBQUM7SUFDSjtJQUNBLE9BQU9GLElBQUk7RUFDYjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtFQUNFRyxhQUFhQSxDQUFBLENBQUUsRUFBRSxNQUFNLENBQUM7SUFDdEIsSUFBSSxDQUFDNVEsWUFBWSxDQUFDLElBQUksQ0FBQ2tHLFNBQVMsQ0FBQyxFQUFFLE9BQU8sRUFBRTtJQUM1QyxNQUFNdUssSUFBSSxHQUFHLElBQUksQ0FBQ0Qsb0JBQW9CLENBQUMsQ0FBQztJQUN4QzlRLGNBQWMsQ0FBQyxJQUFJLENBQUN3RyxTQUFTLENBQUM7SUFDOUIsSUFBSSxDQUFDMksscUJBQXFCLENBQUMsQ0FBQztJQUM1QixPQUFPSixJQUFJO0VBQ2I7O0VBRUE7RUFDQUssa0JBQWtCQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDekIsSUFBSSxDQUFDOVEsWUFBWSxDQUFDLElBQUksQ0FBQ2tHLFNBQVMsQ0FBQyxFQUFFO0lBQ25DeEcsY0FBYyxDQUFDLElBQUksQ0FBQ3dHLFNBQVMsQ0FBQztJQUM5QixJQUFJLENBQUMySyxxQkFBcUIsQ0FBQyxDQUFDO0VBQzlCOztFQUVBO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VFLGtCQUFrQkEsQ0FBQ0MsS0FBSyxFQUFFLE1BQU0sQ0FBQyxFQUFFLElBQUksQ0FBQztJQUN0QyxJQUFJLElBQUksQ0FBQzdLLG9CQUFvQixLQUFLNkssS0FBSyxFQUFFO0lBQ3pDLElBQUksQ0FBQzdLLG9CQUFvQixHQUFHNkssS0FBSztJQUNqQyxJQUFJLENBQUNoTixjQUFjLENBQUMsQ0FBQztFQUN2Qjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFaU4sa0JBQWtCQSxDQUFDQyxFQUFFLEVBQUVoVSxHQUFHLENBQUNvSCxVQUFVLENBQUMsRUFBRTNGLGFBQWEsRUFBRSxDQUFDO0lBQ3RELElBQUksQ0FBQyxJQUFJLENBQUN3SCxvQkFBb0IsSUFBSSxDQUFDK0ssRUFBRSxDQUFDekksUUFBUSxFQUFFLE9BQU8sRUFBRTtJQUN6RCxNQUFNYSxLQUFLLEdBQUcyRSxJQUFJLENBQUNrRCxJQUFJLENBQUNELEVBQUUsQ0FBQ3pJLFFBQVEsQ0FBQzJJLGdCQUFnQixDQUFDLENBQUMsQ0FBQztJQUN2RCxNQUFNL0gsTUFBTSxHQUFHNEUsSUFBSSxDQUFDa0QsSUFBSSxDQUFDRCxFQUFFLENBQUN6SSxRQUFRLENBQUM0SSxpQkFBaUIsQ0FBQyxDQUFDLENBQUM7SUFDekQsSUFBSS9ILEtBQUssSUFBSSxDQUFDLElBQUlELE1BQU0sSUFBSSxDQUFDLEVBQUUsT0FBTyxFQUFFO0lBQ3hDO0lBQ0E7SUFDQSxNQUFNaUksTUFBTSxHQUFHSixFQUFFLENBQUN6SSxRQUFRLENBQUM4SSxlQUFlLENBQUMsQ0FBQztJQUM1QyxNQUFNQyxLQUFLLEdBQUdOLEVBQUUsQ0FBQ3pJLFFBQVEsQ0FBQ2dKLGNBQWMsQ0FBQyxDQUFDO0lBQzFDLE1BQU1yRyxNQUFNLEdBQUdsTSxZQUFZLENBQ3pCb0ssS0FBSyxFQUNMRCxNQUFNLEVBQ04sSUFBSSxDQUFDNUUsU0FBUyxFQUNkLElBQUksQ0FBQ0MsUUFBUSxFQUNiLElBQUksQ0FBQ0MsYUFDUCxDQUFDO0lBQ0QsTUFBTStNLE1BQU0sR0FBRyxJQUFJNVQsTUFBTSxDQUFDO01BQ3hCd0wsS0FBSztNQUNMRCxNQUFNO01BQ041RSxTQUFTLEVBQUUsSUFBSSxDQUFDQSxTQUFTO01BQ3pCMkc7SUFDRixDQUFDLENBQUM7SUFDRjdNLGtCQUFrQixDQUFDMlMsRUFBRSxFQUFFUSxNQUFNLEVBQUU7TUFDN0JDLE9BQU8sRUFBRSxDQUFDTCxNQUFNO01BQ2hCTSxPQUFPLEVBQUUsQ0FBQ0osS0FBSztNQUNmSyxVQUFVLEVBQUVuRTtJQUNkLENBQUMsQ0FBQztJQUNGLE1BQU1vRSxRQUFRLEdBQUdKLE1BQU0sQ0FBQ2xFLEdBQUcsQ0FBQyxDQUFDO0lBQzdCO0lBQ0E7SUFDQTtJQUNBO0lBQ0F0USxHQUFHLENBQUM2VSxTQUFTLENBQUNiLEVBQUUsQ0FBQztJQUNqQixNQUFNN0ssU0FBUyxHQUFHekgsYUFBYSxDQUFDa1QsUUFBUSxFQUFFLElBQUksQ0FBQzNMLG9CQUFvQixDQUFDO0lBQ3BFekosZUFBZSxDQUNiLDBCQUEwQixJQUFJLENBQUN5SixvQkFBb0IsSUFBSSxHQUNyRCxNQUFNbUQsS0FBSyxJQUFJRCxNQUFNLEtBQUtpSSxNQUFNLElBQUlFLEtBQUssT0FBT25MLFNBQVMsQ0FBQ3lHLE1BQU0sR0FBRyxHQUNuRSxJQUFJekcsU0FBUyxDQUNWMkwsS0FBSyxDQUFDLENBQUMsRUFBRSxFQUFFLENBQUMsQ0FDWkMsR0FBRyxDQUFDQyxDQUFDLElBQUksR0FBR0EsQ0FBQyxDQUFDbkgsR0FBRyxJQUFJbUgsQ0FBQyxDQUFDOUQsR0FBRyxFQUFFLENBQUMsQ0FDN0JyQixJQUFJLENBQUMsR0FBRyxDQUFDLEVBQUUsR0FDZCxHQUFHMUcsU0FBUyxDQUFDeUcsTUFBTSxHQUFHLEVBQUUsR0FBRyxJQUFJLEdBQUcsRUFBRSxHQUN4QyxDQUFDO0lBQ0QsT0FBT3pHLFNBQVM7RUFDbEI7O0VBRUE7QUFDRjtBQUNBO0FBQ0E7QUFDQTtFQUNFOEwsa0JBQWtCQSxDQUNoQkMsS0FBSyxFQUFFO0lBQ0wvTCxTQUFTLEVBQUUxSCxhQUFhLEVBQUU7SUFDMUIySCxTQUFTLEVBQUUsTUFBTTtJQUNqQkMsVUFBVSxFQUFFLE1BQU07RUFDcEIsQ0FBQyxHQUFHLElBQUksQ0FDVCxFQUFFLElBQUksQ0FBQztJQUNOLElBQUksQ0FBQ0gsZUFBZSxHQUFHZ00sS0FBSztJQUM1QixJQUFJLENBQUNwTyxjQUFjLENBQUMsQ0FBQztFQUN2Qjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VxTyxtQkFBbUJBLENBQUNDLEtBQUssRUFBRSxNQUFNLENBQUMsRUFBRSxJQUFJLENBQUM7SUFDdkM7SUFDQTtJQUNBO0lBQ0EsTUFBTUMsT0FBTyxHQUFHMVYsUUFBUSxDQUFDLElBQUksRUFBRXlWLEtBQUssRUFBRSxZQUFZLENBQUM7SUFDbkQsTUFBTUUsR0FBRyxHQUFHRCxPQUFPLENBQUNFLE9BQU8sQ0FBQyxJQUFJLENBQUM7SUFDakMsSUFBSUQsR0FBRyxJQUFJLENBQUMsSUFBSUEsR0FBRyxLQUFLRCxPQUFPLENBQUN6RixNQUFNLEdBQUcsQ0FBQyxFQUFFO01BQzFDLElBQUksQ0FBQ3JJLFNBQVMsQ0FBQ2lPLGNBQWMsQ0FBQyxJQUFJLENBQUM7TUFDbkM7SUFDRjtJQUNBLElBQUksQ0FBQ2pPLFNBQVMsQ0FBQ2lPLGNBQWMsQ0FBQztNQUM1QmhRLElBQUksRUFBRSxNQUFNO01BQ1ppUSxJQUFJLEVBQUVKLE9BQU8sQ0FBQ1AsS0FBSyxDQUFDLENBQUMsRUFBRVEsR0FBRyxDQUFDO01BQzNCSSxPQUFPLEVBQUVMLE9BQU8sQ0FBQ1AsS0FBSyxDQUFDUSxHQUFHLEdBQUcsQ0FBQyxDQUFDLENBQUU7SUFDbkMsQ0FBQyxDQUFDO0lBQ0Y7SUFDQTtJQUNBO0VBQ0Y7O0VBRUE7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0UvUyxtQkFBbUJBLENBQ2pCb1QsUUFBUSxFQUFFLE1BQU0sRUFDaEJDLE9BQU8sRUFBRSxNQUFNLEVBQ2ZDLElBQUksRUFBRSxPQUFPLEdBQUcsT0FBTyxDQUN4QixFQUFFLElBQUksQ0FBQztJQUNOdFQsbUJBQW1CLENBQ2pCLElBQUksQ0FBQ3lHLFNBQVMsRUFDZCxJQUFJLENBQUNoQixVQUFVLENBQUNrRyxNQUFNLEVBQ3RCeUgsUUFBUSxFQUNSQyxPQUFPLEVBQ1BDLElBQ0YsQ0FBQztFQUNIOztFQUVBO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VDLHVCQUF1QkEsQ0FBQ0MsSUFBSSxFQUFFLE1BQU0sRUFBRUMsTUFBTSxFQUFFLE1BQU0sRUFBRUMsTUFBTSxFQUFFLE1BQU0sQ0FBQyxFQUFFLElBQUksQ0FBQztJQUMxRSxNQUFNQyxNQUFNLEdBQUdwVCxZQUFZLENBQUMsSUFBSSxDQUFDa0csU0FBUyxDQUFDO0lBQzNDNUYsY0FBYyxDQUNaLElBQUksQ0FBQzRGLFNBQVMsRUFDZCtNLElBQUksRUFDSkMsTUFBTSxFQUNOQyxNQUFNLEVBQ04sSUFBSSxDQUFDak8sVUFBVSxDQUFDa0csTUFBTSxDQUFDOUIsS0FDekIsQ0FBQztJQUNEO0lBQ0E7SUFDQTtJQUNBO0lBQ0EsSUFBSThKLE1BQU0sSUFBSSxDQUFDcFQsWUFBWSxDQUFDLElBQUksQ0FBQ2tHLFNBQVMsQ0FBQyxFQUFFO01BQzNDLElBQUksQ0FBQzJLLHFCQUFxQixDQUFDLENBQUM7SUFDOUI7RUFDRjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0V3QyxrQkFBa0JBLENBQUNDLElBQUksRUFBRXpULFNBQVMsQ0FBQyxFQUFFLElBQUksQ0FBQztJQUN4QyxJQUFJLENBQUMsSUFBSSxDQUFDOEcsZUFBZSxFQUFFO0lBQzNCLE1BQU07TUFBRTBFO0lBQU0sQ0FBQyxHQUFHLElBQUksQ0FBQ25GLFNBQVM7SUFDaEMsSUFBSSxDQUFDbUYsS0FBSyxFQUFFO0lBQ1osTUFBTTtNQUFFL0IsS0FBSztNQUFFRDtJQUFPLENBQUMsR0FBRyxJQUFJLENBQUNuRSxVQUFVLENBQUNrRyxNQUFNO0lBQ2hELE1BQU1tSSxNQUFNLEdBQUdqSyxLQUFLLEdBQUcsQ0FBQztJQUN4QixNQUFNNkosTUFBTSxHQUFHOUosTUFBTSxHQUFHLENBQUM7SUFDekIsSUFBSTtNQUFFK0UsR0FBRztNQUFFckQ7SUFBSSxDQUFDLEdBQUdNLEtBQUs7SUFDeEIsUUFBUWlJLElBQUk7TUFDVixLQUFLLE1BQU07UUFDVCxJQUFJbEYsR0FBRyxHQUFHLENBQUMsRUFBRUEsR0FBRyxFQUFFLE1BQ2IsSUFBSXJELEdBQUcsR0FBRyxDQUFDLEVBQUU7VUFDaEJxRCxHQUFHLEdBQUdtRixNQUFNO1VBQ1p4SSxHQUFHLEVBQUU7UUFDUDtRQUNBO01BQ0YsS0FBSyxPQUFPO1FBQ1YsSUFBSXFELEdBQUcsR0FBR21GLE1BQU0sRUFBRW5GLEdBQUcsRUFBRSxNQUNsQixJQUFJckQsR0FBRyxHQUFHb0ksTUFBTSxFQUFFO1VBQ3JCL0UsR0FBRyxHQUFHLENBQUM7VUFDUHJELEdBQUcsRUFBRTtRQUNQO1FBQ0E7TUFDRixLQUFLLElBQUk7UUFDUCxJQUFJQSxHQUFHLEdBQUcsQ0FBQyxFQUFFQSxHQUFHLEVBQUU7UUFDbEI7TUFDRixLQUFLLE1BQU07UUFDVCxJQUFJQSxHQUFHLEdBQUdvSSxNQUFNLEVBQUVwSSxHQUFHLEVBQUU7UUFDdkI7TUFDRixLQUFLLFdBQVc7UUFDZHFELEdBQUcsR0FBRyxDQUFDO1FBQ1A7TUFDRixLQUFLLFNBQVM7UUFDWkEsR0FBRyxHQUFHbUYsTUFBTTtRQUNaO0lBQ0o7SUFDQSxJQUFJbkYsR0FBRyxLQUFLL0MsS0FBSyxDQUFDK0MsR0FBRyxJQUFJckQsR0FBRyxLQUFLTSxLQUFLLENBQUNOLEdBQUcsRUFBRTtJQUM1QzlLLFNBQVMsQ0FBQyxJQUFJLENBQUNpRyxTQUFTLEVBQUVrSSxHQUFHLEVBQUVyRCxHQUFHLENBQUM7SUFDbkMsSUFBSSxDQUFDOEYscUJBQXFCLENBQUMsQ0FBQztFQUM5Qjs7RUFFQTtFQUNBMkMsZ0JBQWdCQSxDQUFBLENBQUUsRUFBRSxPQUFPLENBQUM7SUFDMUIsT0FBT3hULFlBQVksQ0FBQyxJQUFJLENBQUNrRyxTQUFTLENBQUM7RUFDckM7O0VBRUE7QUFDRjtBQUNBO0FBQ0E7RUFDRXVOLDBCQUEwQkEsQ0FBQ2xJLEVBQUUsRUFBRSxHQUFHLEdBQUcsSUFBSSxDQUFDLEVBQUUsR0FBRyxHQUFHLElBQUksQ0FBQztJQUNyRCxJQUFJLENBQUMvRSxrQkFBa0IsQ0FBQ2tOLEdBQUcsQ0FBQ25JLEVBQUUsQ0FBQztJQUMvQixPQUFPLE1BQU0sSUFBSSxDQUFDL0Usa0JBQWtCLENBQUNtTixNQUFNLENBQUNwSSxFQUFFLENBQUM7RUFDakQ7RUFFQSxRQUFRc0YscUJBQXFCQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDcEMsSUFBSSxDQUFDcEosUUFBUSxDQUFDLENBQUM7SUFDZixLQUFLLE1BQU04RCxFQUFFLElBQUksSUFBSSxDQUFDL0Usa0JBQWtCLEVBQUUrRSxFQUFFLENBQUMsQ0FBQztFQUNoRDs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFL04sYUFBYUEsQ0FBQzRRLEdBQUcsRUFBRSxNQUFNLEVBQUVyRCxHQUFHLEVBQUUsTUFBTSxDQUFDLEVBQUUsT0FBTyxDQUFDO0lBQy9DLElBQUksQ0FBQyxJQUFJLENBQUNwRSxlQUFlLEVBQUUsT0FBTyxLQUFLO0lBQ3ZDLE1BQU00SixLQUFLLEdBQUduUixhQUFhLENBQUMsSUFBSSxDQUFDOEYsVUFBVSxDQUFDa0csTUFBTSxFQUFFZ0QsR0FBRyxFQUFFckQsR0FBRyxDQUFDO0lBQzdELE9BQU92TixhQUFhLENBQUMsSUFBSSxDQUFDNkcsUUFBUSxFQUFFK0osR0FBRyxFQUFFckQsR0FBRyxFQUFFd0YsS0FBSyxDQUFDO0VBQ3REO0VBRUE5UyxhQUFhQSxDQUFDMlEsR0FBRyxFQUFFLE1BQU0sRUFBRXJELEdBQUcsRUFBRSxNQUFNLENBQUMsRUFBRSxJQUFJLENBQUM7SUFDNUMsSUFBSSxDQUFDLElBQUksQ0FBQ3BFLGVBQWUsRUFBRTtJQUMzQmxKLGFBQWEsQ0FBQyxJQUFJLENBQUM0RyxRQUFRLEVBQUUrSixHQUFHLEVBQUVyRCxHQUFHLEVBQUUsSUFBSSxDQUFDckUsWUFBWSxDQUFDO0VBQzNEO0VBRUFrTixxQkFBcUJBLENBQUNDLFNBQVMsRUFBRTlWLFNBQVMsQ0FBQyxFQUFFLElBQUksQ0FBQztJQUNoRCxNQUFNc0ssTUFBTSxHQUFHLElBQUksQ0FBQzlELFlBQVksQ0FBQ3VQLGFBQWEsSUFBSSxJQUFJLENBQUN6UCxRQUFRO0lBQy9ELE1BQU1ULEtBQUssR0FBRyxJQUFJekcsYUFBYSxDQUFDMFcsU0FBUyxDQUFDO0lBQzFDNVYsVUFBVSxDQUFDcUssZ0JBQWdCLENBQUNELE1BQU0sRUFBRXpFLEtBQUssQ0FBQzs7SUFFMUM7SUFDQTtJQUNBLElBQ0UsQ0FBQ0EsS0FBSyxDQUFDbVEsZ0JBQWdCLElBQ3ZCRixTQUFTLENBQUNHLElBQUksS0FBSyxLQUFLLElBQ3hCLENBQUNILFNBQVMsQ0FBQ0ksSUFBSSxJQUNmLENBQUNKLFNBQVMsQ0FBQ0ssSUFBSSxFQUNmO01BQ0EsSUFBSUwsU0FBUyxDQUFDTSxLQUFLLEVBQUU7UUFDbkIsSUFBSSxDQUFDNVAsWUFBWSxDQUFDNlAsYUFBYSxDQUFDLElBQUksQ0FBQy9QLFFBQVEsQ0FBQztNQUNoRCxDQUFDLE1BQU07UUFDTCxJQUFJLENBQUNFLFlBQVksQ0FBQzhQLFNBQVMsQ0FBQyxJQUFJLENBQUNoUSxRQUFRLENBQUM7TUFDNUM7SUFDRjtFQUNGO0VBQ0E7QUFDRjtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0VBQ0VpUSxjQUFjQSxDQUFDbEcsR0FBRyxFQUFFLE1BQU0sRUFBRXJELEdBQUcsRUFBRSxNQUFNLENBQUMsRUFBRSxNQUFNLEdBQUcsU0FBUyxDQUFDO0lBQzNELElBQUksQ0FBQyxJQUFJLENBQUNwRSxlQUFlLEVBQUUsT0FBTytHLFNBQVM7SUFDM0MsTUFBTXRDLE1BQU0sR0FBRyxJQUFJLENBQUNsRyxVQUFVLENBQUNrRyxNQUFNO0lBQ3JDLE1BQU1tSixJQUFJLEdBQUd0VixNQUFNLENBQUNtTSxNQUFNLEVBQUVnRCxHQUFHLEVBQUVyRCxHQUFHLENBQUM7SUFDckMsSUFBSXlKLEdBQUcsR0FBR0QsSUFBSSxFQUFFRSxTQUFTO0lBQ3pCO0lBQ0E7SUFDQSxJQUFJLENBQUNELEdBQUcsSUFBSUQsSUFBSSxFQUFFakwsS0FBSyxLQUFLdkssU0FBUyxDQUFDMlYsVUFBVSxJQUFJdEcsR0FBRyxHQUFHLENBQUMsRUFBRTtNQUMzRG9HLEdBQUcsR0FBR3ZWLE1BQU0sQ0FBQ21NLE1BQU0sRUFBRWdELEdBQUcsR0FBRyxDQUFDLEVBQUVyRCxHQUFHLENBQUMsRUFBRTBKLFNBQVM7SUFDL0M7SUFDQSxPQUFPRCxHQUFHLElBQUkxVSxrQkFBa0IsQ0FBQ3NMLE1BQU0sRUFBRWdELEdBQUcsRUFBRXJELEdBQUcsQ0FBQztFQUNwRDs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtFQUNFNEosZ0JBQWdCLEVBQUUsQ0FBQyxDQUFDSCxHQUFHLEVBQUUsTUFBTSxFQUFFLEdBQUcsSUFBSSxDQUFDLEdBQUcsU0FBUzs7RUFFckQ7QUFDRjtBQUNBO0FBQ0E7QUFDQTtFQUNFSSxhQUFhQSxDQUFDSixHQUFHLEVBQUUsTUFBTSxDQUFDLEVBQUUsSUFBSSxDQUFDO0lBQy9CLElBQUksQ0FBQ0csZ0JBQWdCLEdBQUdILEdBQUcsQ0FBQztFQUM5Qjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtFQUNFSyxnQkFBZ0JBLENBQUN6RyxHQUFHLEVBQUUsTUFBTSxFQUFFckQsR0FBRyxFQUFFLE1BQU0sRUFBRStKLEtBQUssRUFBRSxDQUFDLEdBQUcsQ0FBQyxDQUFDLEVBQUUsSUFBSSxDQUFDO0lBQzdELElBQUksQ0FBQyxJQUFJLENBQUNuTyxlQUFlLEVBQUU7SUFDM0IsTUFBTXlFLE1BQU0sR0FBRyxJQUFJLENBQUNsRyxVQUFVLENBQUNrRyxNQUFNO0lBQ3JDO0lBQ0E7SUFDQTtJQUNBNUssY0FBYyxDQUFDLElBQUksQ0FBQzBGLFNBQVMsRUFBRWtJLEdBQUcsRUFBRXJELEdBQUcsQ0FBQztJQUN4QyxJQUFJK0osS0FBSyxLQUFLLENBQUMsRUFBRTFVLFlBQVksQ0FBQyxJQUFJLENBQUM4RixTQUFTLEVBQUVrRixNQUFNLEVBQUVnRCxHQUFHLEVBQUVyRCxHQUFHLENBQUMsTUFDMUQ1SyxZQUFZLENBQUMsSUFBSSxDQUFDK0YsU0FBUyxFQUFFa0YsTUFBTSxFQUFFTCxHQUFHLENBQUM7SUFDOUM7SUFDQTtJQUNBLElBQUksQ0FBQyxJQUFJLENBQUM3RSxTQUFTLENBQUNtRixLQUFLLEVBQUUsSUFBSSxDQUFDbkYsU0FBUyxDQUFDbUYsS0FBSyxHQUFHLElBQUksQ0FBQ25GLFNBQVMsQ0FBQzRFLE1BQU07SUFDdkUsSUFBSSxDQUFDK0YscUJBQXFCLENBQUMsQ0FBQztFQUM5Qjs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRWtFLG1CQUFtQkEsQ0FBQzNHLEdBQUcsRUFBRSxNQUFNLEVBQUVyRCxHQUFHLEVBQUUsTUFBTSxDQUFDLEVBQUUsSUFBSSxDQUFDO0lBQ2xELElBQUksQ0FBQyxJQUFJLENBQUNwRSxlQUFlLEVBQUU7SUFDM0IsTUFBTXFPLEdBQUcsR0FBRyxJQUFJLENBQUM5TyxTQUFTO0lBQzFCLElBQUk4TyxHQUFHLENBQUNDLFVBQVUsRUFBRTtNQUNsQnJWLGVBQWUsQ0FBQ29WLEdBQUcsRUFBRSxJQUFJLENBQUM5UCxVQUFVLENBQUNrRyxNQUFNLEVBQUVnRCxHQUFHLEVBQUVyRCxHQUFHLENBQUM7SUFDeEQsQ0FBQyxNQUFNO01BQ0x0SyxlQUFlLENBQUN1VSxHQUFHLEVBQUU1RyxHQUFHLEVBQUVyRCxHQUFHLENBQUM7SUFDaEM7SUFDQSxJQUFJLENBQUM4RixxQkFBcUIsQ0FBQyxDQUFDO0VBQzlCOztFQUVBO0VBQ0E7RUFDQSxRQUFRcUUsY0FBYyxFQUFFQyxLQUFLLENBQUM7SUFDNUJ2UixLQUFLLEVBQUUsTUFBTTtJQUNid1IsUUFBUSxFQUFFLENBQUMsR0FBR0MsSUFBSSxFQUFFLE9BQU8sRUFBRSxFQUFFLEdBQUcsSUFBSTtFQUN4QyxDQUFDLENBQUMsR0FBRyxFQUFFO0VBQ1AsUUFBUUMsVUFBVSxHQUFHLEtBQUs7RUFFMUJwTCxZQUFZQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDbkIsTUFBTTlHLEtBQUssR0FBRyxJQUFJLENBQUM4RCxPQUFPLENBQUM5RCxLQUFLO0lBQ2hDLElBQUksQ0FBQ0EsS0FBSyxDQUFDa0UsS0FBSyxFQUFFO01BQ2hCO0lBQ0Y7O0lBRUE7SUFDQTtJQUNBLE1BQU1pTyxpQkFBaUIsR0FBR25TLEtBQUssQ0FBQ29TLFNBQVMsQ0FBQyxVQUFVLENBQUM7SUFDckQ5WSxlQUFlLENBQ2Isa0NBQWtDNlksaUJBQWlCLENBQUN6SSxNQUFNLHFDQUFxQyxDQUFDMUosS0FBSyxJQUFJRixNQUFNLENBQUNHLFVBQVUsR0FBRztNQUFFOE0sS0FBSyxDQUFDLEVBQUUsT0FBTztJQUFDLENBQUMsRUFBRUEsS0FBSyxJQUFJLEtBQUssRUFDbEssQ0FBQztJQUNEb0YsaUJBQWlCLENBQUNFLE9BQU8sQ0FBQ0wsUUFBUSxJQUFJO01BQ3BDLElBQUksQ0FBQ0YsY0FBYyxDQUFDN0ksSUFBSSxDQUFDO1FBQ3ZCekksS0FBSyxFQUFFLFVBQVU7UUFDakJ3UixRQUFRLEVBQUVBLFFBQVEsSUFBSSxDQUFDLEdBQUdDLElBQUksRUFBRSxPQUFPLEVBQUUsRUFBRSxHQUFHO01BQ2hELENBQUMsQ0FBQztNQUNGalMsS0FBSyxDQUFDc1MsY0FBYyxDQUFDLFVBQVUsRUFBRU4sUUFBUSxJQUFJLENBQUMsR0FBR0MsSUFBSSxFQUFFLE9BQU8sRUFBRSxFQUFFLEdBQUcsSUFBSSxDQUFDO0lBQzVFLENBQUMsQ0FBQzs7SUFFRjtJQUNBLE1BQU1NLFlBQVksR0FBR3ZTLEtBQUssSUFBSUYsTUFBTSxDQUFDRyxVQUFVLEdBQUc7TUFDaEQ4TSxLQUFLLENBQUMsRUFBRSxPQUFPO01BQ2ZDLFVBQVUsQ0FBQyxFQUFFLENBQUN3RixJQUFJLEVBQUUsT0FBTyxFQUFFLEdBQUcsSUFBSTtJQUN0QyxDQUFDO0lBQ0QsSUFBSUQsWUFBWSxDQUFDeEYsS0FBSyxJQUFJd0YsWUFBWSxDQUFDdkYsVUFBVSxFQUFFO01BQ2pEdUYsWUFBWSxDQUFDdkYsVUFBVSxDQUFDLEtBQUssQ0FBQztNQUM5QixJQUFJLENBQUNrRixVQUFVLEdBQUcsSUFBSTtJQUN4QjtFQUNGO0VBRUFsTCxXQUFXQSxDQUFBLENBQUUsRUFBRSxJQUFJLENBQUM7SUFDbEIsTUFBTWhILEtBQUssR0FBRyxJQUFJLENBQUM4RCxPQUFPLENBQUM5RCxLQUFLO0lBQ2hDLElBQUksQ0FBQ0EsS0FBSyxDQUFDa0UsS0FBSyxFQUFFO01BQ2hCO0lBQ0Y7O0lBRUE7SUFDQSxJQUFJLElBQUksQ0FBQzROLGNBQWMsQ0FBQ3BJLE1BQU0sS0FBSyxDQUFDLElBQUksQ0FBQyxJQUFJLENBQUN3SSxVQUFVLEVBQUU7TUFDeEQ1WSxlQUFlLENBQ2IsNkZBQTZGLEVBQzdGO1FBQUVzUSxLQUFLLEVBQUU7TUFBTyxDQUNsQixDQUFDO0lBQ0g7SUFDQXRRLGVBQWUsQ0FDYixxQ0FBcUMsSUFBSSxDQUFDd1ksY0FBYyxDQUFDcEksTUFBTSw0QkFBNEIsSUFBSSxDQUFDd0ksVUFBVSxFQUM1RyxDQUFDO0lBQ0QsSUFBSSxDQUFDSixjQUFjLENBQUNPLE9BQU8sQ0FBQyxDQUFDO01BQUU3UixLQUFLO01BQUV3UjtJQUFTLENBQUMsS0FBSztNQUNuRGhTLEtBQUssQ0FBQ3lTLFdBQVcsQ0FBQ2pTLEtBQUssRUFBRXdSLFFBQVEsQ0FBQztJQUNwQyxDQUFDLENBQUM7SUFDRixJQUFJLENBQUNGLGNBQWMsR0FBRyxFQUFFOztJQUV4QjtJQUNBLElBQUksSUFBSSxDQUFDSSxVQUFVLEVBQUU7TUFDbkIsTUFBTUssWUFBWSxHQUFHdlMsS0FBSyxJQUFJRixNQUFNLENBQUNHLFVBQVUsR0FBRztRQUNoRCtNLFVBQVUsQ0FBQyxFQUFFLENBQUN3RixJQUFJLEVBQUUsT0FBTyxFQUFFLEdBQUcsSUFBSTtNQUN0QyxDQUFDO01BQ0QsSUFBSUQsWUFBWSxDQUFDdkYsVUFBVSxFQUFFO1FBQzNCdUYsWUFBWSxDQUFDdkYsVUFBVSxDQUFDLElBQUksQ0FBQztNQUMvQjtNQUNBLElBQUksQ0FBQ2tGLFVBQVUsR0FBRyxLQUFLO0lBQ3pCO0VBQ0Y7O0VBRUE7RUFDQTtFQUNBO0VBQ0E7RUFDQSxRQUFRUSxRQUFRQSxDQUFDQyxJQUFJLEVBQUUsTUFBTSxDQUFDLEVBQUUsSUFBSSxDQUFDO0lBQ25DLElBQUksQ0FBQzdPLE9BQU8sQ0FBQ2pFLE1BQU0sQ0FBQ3dHLEtBQUssQ0FBQ3NNLElBQUksQ0FBQztFQUNqQztFQUVBLFFBQVFDLG9CQUFvQixFQUFFaFosdUJBQXVCLEdBQUdnWixDQUN0RDFJLElBQUksRUFDSjJJLFdBQVcsS0FDUjtJQUNILElBQ0UzSSxJQUFJLEtBQUssSUFBSSxJQUNiMkksV0FBVyxLQUFLdkksU0FBUyxJQUN6QixJQUFJLENBQUMzRyxpQkFBaUIsRUFBRTBHLElBQUksS0FBS3dJLFdBQVcsRUFDNUM7TUFDQTtJQUNGO0lBQ0EsSUFBSSxDQUFDbFAsaUJBQWlCLEdBQUd1RyxJQUFJO0VBQy9CLENBQUM7RUFFRDNELE1BQU1BLENBQUM4RCxJQUFJLEVBQUVyUixTQUFTLENBQUMsRUFBRSxJQUFJLENBQUM7SUFDNUIsSUFBSSxDQUFDNkksV0FBVyxHQUFHd0ksSUFBSTtJQUV2QixNQUFNeUksSUFBSSxHQUNSLENBQUMsR0FBRyxDQUNGLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQ2hQLE9BQU8sQ0FBQzlELEtBQUssQ0FBQyxDQUMxQixNQUFNLENBQUMsQ0FBQyxJQUFJLENBQUM4RCxPQUFPLENBQUNqRSxNQUFNLENBQUMsQ0FDNUIsTUFBTSxDQUFDLENBQUMsSUFBSSxDQUFDaUUsT0FBTyxDQUFDNUQsTUFBTSxDQUFDLENBQzVCLFdBQVcsQ0FBQyxDQUFDLElBQUksQ0FBQzRELE9BQU8sQ0FBQzNELFdBQVcsQ0FBQyxDQUN0QyxNQUFNLENBQUMsQ0FBQyxJQUFJLENBQUNzRSxPQUFPLENBQUMsQ0FDckIsZUFBZSxDQUFDLENBQUMsSUFBSSxDQUFDN0MsZUFBZSxDQUFDLENBQ3RDLFlBQVksQ0FBQyxDQUFDLElBQUksQ0FBQ2pDLFlBQVksQ0FBQyxDQUNoQyxTQUFTLENBQUMsQ0FBQyxJQUFJLENBQUNtRCxTQUFTLENBQUMsQ0FDMUIsaUJBQWlCLENBQUMsQ0FBQyxJQUFJLENBQUMySyxxQkFBcUIsQ0FBQyxDQUM5QyxTQUFTLENBQUMsQ0FBQyxJQUFJLENBQUNyVCxhQUFhLENBQUMsQ0FDOUIsU0FBUyxDQUFDLENBQUMsSUFBSSxDQUFDQyxhQUFhLENBQUMsQ0FDOUIsY0FBYyxDQUFDLENBQUMsSUFBSSxDQUFDNlcsY0FBYyxDQUFDLENBQ3BDLGVBQWUsQ0FBQyxDQUFDLElBQUksQ0FBQ00sYUFBYSxDQUFDLENBQ3BDLFlBQVksQ0FBQyxDQUFDLElBQUksQ0FBQ0MsZ0JBQWdCLENBQUMsQ0FDcEMsZUFBZSxDQUFDLENBQUMsSUFBSSxDQUFDRSxtQkFBbUIsQ0FBQyxDQUMxQyxhQUFhLENBQUMsQ0FBQyxJQUFJLENBQUMvRSxxQkFBcUIsQ0FBQyxDQUMxQyxtQkFBbUIsQ0FBQyxDQUFDLElBQUksQ0FBQ2dHLG9CQUFvQixDQUFDLENBQy9DLHFCQUFxQixDQUFDLENBQUMsSUFBSSxDQUFDcEMscUJBQXFCLENBQUM7QUFFMUQsUUFBUSxDQUFDLHFCQUFxQixDQUFDLEtBQUssQ0FBQyxDQUFDLElBQUksQ0FBQ2tDLFFBQVEsQ0FBQztBQUNwRCxVQUFVLENBQUNySSxJQUFJO0FBQ2YsUUFBUSxFQUFFLHFCQUFxQjtBQUMvQixNQUFNLEVBQUUsR0FBRyxDQUNOOztJQUVEO0lBQ0F6UCxVQUFVLENBQUNtWSxtQkFBbUIsQ0FBQ0QsSUFBSSxFQUFFLElBQUksQ0FBQzlSLFNBQVMsRUFBRSxJQUFJLEVBQUVuSSxJQUFJLENBQUM7SUFDaEU7SUFDQStCLFVBQVUsQ0FBQ29ZLGFBQWEsQ0FBQyxDQUFDO0VBQzVCO0VBRUF2TyxPQUFPQSxDQUFDd08sS0FBNkIsQ0FBdkIsRUFBRXRNLEtBQUssR0FBRyxNQUFNLEdBQUcsSUFBSSxDQUFDLEVBQUUsSUFBSSxDQUFDO0lBQzNDLElBQUksSUFBSSxDQUFDN0YsV0FBVyxFQUFFO01BQ3BCO0lBQ0Y7SUFFQSxJQUFJLENBQUN1RCxRQUFRLENBQUMsQ0FBQztJQUNmLElBQUksQ0FBQ0csZUFBZSxDQUFDLENBQUM7SUFFdEIsSUFBSSxPQUFPLElBQUksQ0FBQy9DLGNBQWMsS0FBSyxVQUFVLEVBQUU7TUFDN0MsSUFBSSxDQUFDQSxjQUFjLENBQUMsQ0FBQztJQUN2QjtJQUNBLElBQUksQ0FBQ0MsYUFBYSxHQUFHLENBQUM7SUFFdEIsSUFBSSxDQUFDQyxzQkFBc0IsR0FBRyxDQUFDOztJQUUvQjtJQUNBO0lBQ0EsTUFBTWlILElBQUksR0FBRyxJQUFJLENBQUNsSSxHQUFHLENBQUN3UywrQkFBK0IsQ0FBQyxJQUFJLENBQUNwUixVQUFVLENBQUM7SUFDdEVyRSxtQkFBbUIsQ0FBQyxJQUFJLENBQUNrRCxRQUFRLEVBQUVsRyxRQUFRLENBQUNtTyxJQUFJLENBQUMsQ0FBQzs7SUFFbEQ7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBO0lBQ0E7SUFDQTtJQUNBLElBQUksSUFBSSxDQUFDOUUsT0FBTyxDQUFDakUsTUFBTSxDQUFDcUUsS0FBSyxFQUFFO01BQzdCLElBQUksSUFBSSxDQUFDWCxlQUFlLEVBQUU7UUFDeEI7UUFDQTtRQUNBM0ssU0FBUyxDQUFDLENBQUMsRUFBRTJGLGVBQWUsQ0FBQztNQUMvQjtNQUNBO01BQ0E7TUFDQTtNQUNBM0YsU0FBUyxDQUFDLENBQUMsRUFBRXdGLHNCQUFzQixDQUFDO01BQ3BDO01BQ0EsSUFBSSxDQUFDOE8sVUFBVSxDQUFDLENBQUM7TUFDakI7TUFDQXRVLFNBQVMsQ0FBQyxDQUFDLEVBQUVrRix5QkFBeUIsQ0FBQztNQUN2Q2xGLFNBQVMsQ0FBQyxDQUFDLEVBQUVpRixzQkFBc0IsQ0FBQztNQUNwQztNQUNBakYsU0FBUyxDQUFDLENBQUMsRUFBRXVGLEdBQUcsQ0FBQztNQUNqQjtNQUNBdkYsU0FBUyxDQUFDLENBQUMsRUFBRXNGLEdBQUcsQ0FBQztNQUNqQjtNQUNBdEYsU0FBUyxDQUFDLENBQUMsRUFBRTRGLFdBQVcsQ0FBQztNQUN6QjtNQUNBNUYsU0FBUyxDQUFDLENBQUMsRUFBRTZGLHFCQUFxQixDQUFDO01BQ25DO01BQ0EsSUFBSUcsaUJBQWlCLENBQUMsQ0FBQyxFQUNyQmhHLFNBQVMsQ0FBQyxDQUFDLEVBQUVpRyxrQkFBa0IsQ0FBQ0gsZ0JBQWdCLENBQUMsQ0FBQztJQUN0RDtJQUNBOztJQUVBLElBQUksQ0FBQ29DLFdBQVcsR0FBRyxJQUFJOztJQUV2QjtJQUNBLElBQUksQ0FBQ0YsY0FBYyxDQUFDQyxNQUFNLEdBQUcsQ0FBQztJQUM5QixJQUFJLElBQUksQ0FBQ3NCLFVBQVUsS0FBSyxJQUFJLEVBQUU7TUFDNUJnRixZQUFZLENBQUMsSUFBSSxDQUFDaEYsVUFBVSxDQUFDO01BQzdCLElBQUksQ0FBQ0EsVUFBVSxHQUFHLElBQUk7SUFDeEI7O0lBRUE7SUFDQXZILFVBQVUsQ0FBQ21ZLG1CQUFtQixDQUFDLElBQUksRUFBRSxJQUFJLENBQUMvUixTQUFTLEVBQUUsSUFBSSxFQUFFbkksSUFBSSxDQUFDO0lBQ2hFO0lBQ0ErQixVQUFVLENBQUNvWSxhQUFhLENBQUMsQ0FBQztJQUMxQjFZLFNBQVMsQ0FBQ2lXLE1BQU0sQ0FBQyxJQUFJLENBQUN6TSxPQUFPLENBQUNqRSxNQUFNLENBQUM7O0lBRXJDO0lBQ0E7SUFDQTtJQUNBLElBQUksQ0FBQ29CLFFBQVEsQ0FBQ29FLFFBQVEsRUFBRThOLElBQUksQ0FBQyxDQUFDO0lBQzlCLElBQUksQ0FBQ2xTLFFBQVEsQ0FBQ29FLFFBQVEsR0FBR2lGLFNBQVM7SUFFbEMsSUFBSTJJLEtBQUssWUFBWXRNLEtBQUssRUFBRTtNQUMxQixJQUFJLENBQUNGLGlCQUFpQixDQUFDd00sS0FBSyxDQUFDO0lBQy9CLENBQUMsTUFBTTtNQUNMLElBQUksQ0FBQ3pNLGtCQUFrQixDQUFDLENBQUM7SUFDM0I7RUFDRjtFQUVBLE1BQU1uRyxhQUFhQSxDQUFBLENBQUUsRUFBRUMsT0FBTyxDQUFDLElBQUksQ0FBQyxDQUFDO0lBQ25DLElBQUksQ0FBQ2tCLFdBQVcsS0FBSyxJQUFJbEIsT0FBTyxDQUFDLENBQUM4UyxPQUFPLEVBQUVDLE1BQU0sS0FBSztNQUNwRCxJQUFJLENBQUM3TSxrQkFBa0IsR0FBRzRNLE9BQU87TUFDakMsSUFBSSxDQUFDM00saUJBQWlCLEdBQUc0TSxNQUFNO0lBQ2pDLENBQUMsQ0FBQztJQUVGLE9BQU8sSUFBSSxDQUFDN1IsV0FBVztFQUN6QjtFQUVBOFIsY0FBY0EsQ0FBQSxDQUFFLEVBQUUsSUFBSSxDQUFDO0lBQ3JCLElBQUksSUFBSSxDQUFDeFAsT0FBTyxDQUFDakUsTUFBTSxDQUFDcUUsS0FBSyxFQUFFO01BQzdCO01BQ0EsSUFBSSxDQUFDbkMsU0FBUyxHQUFHLElBQUksQ0FBQ0QsVUFBVTtNQUNoQyxJQUFJLENBQUNBLFVBQVUsR0FBRzdILFVBQVUsQ0FDMUIsSUFBSSxDQUFDNkgsVUFBVSxDQUFDa0UsUUFBUSxDQUFDQyxNQUFNLEVBQy9CLElBQUksQ0FBQ25FLFVBQVUsQ0FBQ2tFLFFBQVEsQ0FBQ0UsS0FBSyxFQUM5QixJQUFJLENBQUM3RSxTQUFTLEVBQ2QsSUFBSSxDQUFDQyxRQUFRLEVBQ2IsSUFBSSxDQUFDQyxhQUNQLENBQUM7TUFDRCxJQUFJLENBQUNiLEdBQUcsQ0FBQ3lGLEtBQUssQ0FBQyxDQUFDO01BQ2hCO01BQ0E7TUFDQSxJQUFJLENBQUN2QyxhQUFhLEdBQUcsSUFBSTtJQUMzQjtFQUNGOztFQUVBO0FBQ0Y7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRWtGLFVBQVVBLENBQUEsQ0FBRSxFQUFFLElBQUksQ0FBQztJQUNqQixJQUFJLENBQUN4SCxRQUFRLEdBQUcsSUFBSTFGLFFBQVEsQ0FBQyxDQUFDO0lBQzlCLElBQUksQ0FBQzJGLGFBQWEsR0FBRyxJQUFJeEYsYUFBYSxDQUFDLENBQUM7SUFDeENFLGtCQUFrQixDQUNoQixJQUFJLENBQUM2RixVQUFVLENBQUNrRyxNQUFNLEVBQ3RCLElBQUksQ0FBQzFHLFFBQVEsRUFDYixJQUFJLENBQUNDLGFBQ1AsQ0FBQztJQUNEO0lBQ0E7SUFDQTtJQUNBLElBQUksQ0FBQ1EsU0FBUyxDQUFDaUcsTUFBTSxDQUFDMUcsUUFBUSxHQUFHLElBQUksQ0FBQ0EsUUFBUTtJQUM5QyxJQUFJLENBQUNTLFNBQVMsQ0FBQ2lHLE1BQU0sQ0FBQ3pHLGFBQWEsR0FBRyxJQUFJLENBQUNBLGFBQWE7RUFDMUQ7RUFFQW5CLFlBQVlBLENBQUEsQ0FBRSxFQUFFLEdBQUcsR0FBRyxJQUFJLENBQUM7SUFDekI7SUFDQSxNQUFNbVQsR0FBRyxHQUFHQyxPQUFPO0lBQ25CLE1BQU1DLFNBQVMsRUFBRUMsT0FBTyxDQUFDQyxNQUFNLENBQUMsTUFBTUMsT0FBTyxFQUFFQSxPQUFPLENBQUMsTUFBTUEsT0FBTyxDQUFDLENBQUMsQ0FBQyxHQUFHLENBQUMsQ0FBQztJQUM1RSxNQUFNQyxPQUFPLEdBQUdBLENBQUMsR0FBRzVCLElBQUksRUFBRSxPQUFPLEVBQUUsS0FDakMzWSxlQUFlLENBQUMsZ0JBQWdCRSxNQUFNLENBQUMsR0FBR3lZLElBQUksQ0FBQyxFQUFFLENBQUM7SUFDcEQsTUFBTTZCLE9BQU8sR0FBR0EsQ0FBQyxHQUFHN0IsSUFBSSxFQUFFLE9BQU8sRUFBRSxLQUNqQzFZLFFBQVEsQ0FBQyxJQUFJb04sS0FBSyxDQUFDLGtCQUFrQm5OLE1BQU0sQ0FBQyxHQUFHeVksSUFBSSxDQUFDLEVBQUUsQ0FBQyxDQUFDO0lBQzFELEtBQUssTUFBTWhGLENBQUMsSUFBSThHLHNCQUFzQixFQUFFO01BQ3RDTixTQUFTLENBQUN4RyxDQUFDLENBQUMsR0FBR3NHLEdBQUcsQ0FBQ3RHLENBQUMsQ0FBQztNQUNyQnNHLEdBQUcsQ0FBQ3RHLENBQUMsQ0FBQyxHQUFHNEcsT0FBTztJQUNsQjtJQUNBLEtBQUssTUFBTTVHLENBQUMsSUFBSStHLHNCQUFzQixFQUFFO01BQ3RDUCxTQUFTLENBQUN4RyxDQUFDLENBQUMsR0FBR3NHLEdBQUcsQ0FBQ3RHLENBQUMsQ0FBQztNQUNyQnNHLEdBQUcsQ0FBQ3RHLENBQUMsQ0FBQyxHQUFHNkcsT0FBTztJQUNsQjtJQUNBTCxTQUFTLENBQUNRLE1BQU0sR0FBR1YsR0FBRyxDQUFDVSxNQUFNO0lBQzdCVixHQUFHLENBQUNVLE1BQU0sR0FBRyxDQUFDQyxTQUFTLEVBQUUsT0FBTyxFQUFFLEdBQUdqQyxJQUFJLEVBQUUsT0FBTyxFQUFFLEtBQUs7TUFDdkQsSUFBSSxDQUFDaUMsU0FBUyxFQUFFSixPQUFPLENBQUMsR0FBRzdCLElBQUksQ0FBQztJQUNsQyxDQUFDO0lBQ0QsT0FBTyxNQUFNalQsTUFBTSxDQUFDbVYsTUFBTSxDQUFDWixHQUFHLEVBQUVFLFNBQVMsQ0FBQztFQUM1Qzs7RUFFQTtBQUNGO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7RUFDRSxRQUFRMVAsV0FBV0EsQ0FBQSxDQUFFLEVBQUUsR0FBRyxHQUFHLElBQUksQ0FBQztJQUNoQyxNQUFNN0QsTUFBTSxHQUFHMkUsT0FBTyxDQUFDM0UsTUFBTTtJQUM3QixNQUFNa1UsYUFBYSxHQUFHbFUsTUFBTSxDQUFDbUcsS0FBSztJQUNsQyxJQUFJZ08sU0FBUyxHQUFHLEtBQUs7SUFDckIsTUFBTUMsU0FBUyxHQUFHQSxDQUNoQkMsS0FBSyxFQUFFQyxVQUFVLEdBQUcsTUFBTSxFQUMxQkMsWUFBdUQsQ0FBMUMsRUFBRUMsY0FBYyxHQUFHLENBQUMsQ0FBQ0MsR0FBVyxDQUFQLEVBQUVoTyxLQUFLLEVBQUUsR0FBRyxJQUFJLENBQUMsRUFDdkR3QixFQUEwQixDQUF2QixFQUFFLENBQUN3TSxHQUFXLENBQVAsRUFBRWhPLEtBQUssRUFBRSxHQUFHLElBQUksQ0FDM0IsRUFBRSxPQUFPLElBQUk7TUFDWixNQUFNaU8sUUFBUSxHQUFHLE9BQU9ILFlBQVksS0FBSyxVQUFVLEdBQUdBLFlBQVksR0FBR3RNLEVBQUU7TUFDdkU7TUFDQTtNQUNBO01BQ0EsSUFBSWtNLFNBQVMsRUFBRTtRQUNiLE1BQU1RLFFBQVEsR0FDWixPQUFPSixZQUFZLEtBQUssUUFBUSxHQUFHQSxZQUFZLEdBQUduSyxTQUFTO1FBQzdELE9BQU84SixhQUFhLENBQUNVLElBQUksQ0FBQzVVLE1BQU0sRUFBRXFVLEtBQUssRUFBRU0sUUFBUSxFQUFFRCxRQUFRLENBQUM7TUFDOUQ7TUFDQVAsU0FBUyxHQUFHLElBQUk7TUFDaEIsSUFBSTtRQUNGLE1BQU1oSCxJQUFJLEdBQ1IsT0FBT2tILEtBQUssS0FBSyxRQUFRLEdBQ3JCQSxLQUFLLEdBQ0xRLE1BQU0sQ0FBQzlKLElBQUksQ0FBQ3NKLEtBQUssQ0FBQyxDQUFDUyxRQUFRLENBQUMsTUFBTSxDQUFDO1FBQ3pDMWIsZUFBZSxDQUFDLFlBQVkrVCxJQUFJLEVBQUUsRUFBRTtVQUFFekQsS0FBSyxFQUFFO1FBQU8sQ0FBQyxDQUFDO1FBQ3RELElBQUksSUFBSSxDQUFDckcsZUFBZSxJQUFJLENBQUMsSUFBSSxDQUFDekMsV0FBVyxJQUFJLENBQUMsSUFBSSxDQUFDQyxRQUFRLEVBQUU7VUFDL0QsSUFBSSxDQUFDMEMscUJBQXFCLEdBQUcsSUFBSTtVQUNqQyxJQUFJLENBQUM3QyxjQUFjLENBQUMsQ0FBQztRQUN2QjtNQUNGLENBQUMsU0FBUztRQUNSeVQsU0FBUyxHQUFHLEtBQUs7UUFDakJPLFFBQVEsR0FBRyxDQUFDO01BQ2Q7TUFDQSxPQUFPLElBQUk7SUFDYixDQUFDO0lBQ0QxVSxNQUFNLENBQUNtRyxLQUFLLEdBQUdpTyxTQUFTO0lBQ3hCLE9BQU8sTUFBTTtNQUNYLElBQUlwVSxNQUFNLENBQUNtRyxLQUFLLEtBQUtpTyxTQUFTLEVBQUU7UUFDOUJwVSxNQUFNLENBQUNtRyxLQUFLLEdBQUcrTixhQUFhO01BQzlCO0lBQ0YsQ0FBQztFQUNIO0FBQ0Y7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQVNsSCxVQUFVQSxDQUFDbE4sS0FBSyxFQUFFRixNQUFNLENBQUNHLFVBQVUsR0FBRzRFLE9BQU8sQ0FBQzdFLEtBQUssQ0FBQyxFQUFFLElBQUksQ0FBQztFQUN6RSxJQUFJLENBQUNBLEtBQUssQ0FBQ2tFLEtBQUssRUFBRTtFQUNsQjtFQUNBO0VBQ0EsSUFBSTtJQUNGLE9BQU9sRSxLQUFLLENBQUNpVixJQUFJLENBQUMsQ0FBQyxLQUFLLElBQUksRUFBRTtNQUM1QjtJQUFBO0VBRUosQ0FBQyxDQUFDLE1BQU07SUFDTjtFQUFBO0VBRUY7RUFDQTtFQUNBLElBQUlwUSxPQUFPLENBQUNxUSxRQUFRLEtBQUssT0FBTyxFQUFFO0VBQ2xDO0VBQ0E7RUFDQTtFQUNBLE1BQU1DLEdBQUcsR0FBR25WLEtBQUssSUFBSUYsTUFBTSxDQUFDRyxVQUFVLEdBQUc7SUFDdkM4TSxLQUFLLENBQUMsRUFBRSxPQUFPO0lBQ2ZDLFVBQVUsQ0FBQyxFQUFFLENBQUNPLEdBQUcsRUFBRSxPQUFPLEVBQUUsR0FBRyxJQUFJO0VBQ3JDLENBQUM7RUFDRCxNQUFNNkgsTUFBTSxHQUFHRCxHQUFHLENBQUNwSSxLQUFLLEtBQUssSUFBSTtFQUNqQztFQUNBO0VBQ0E7RUFDQSxJQUFJc0ksRUFBRSxHQUFHLENBQUMsQ0FBQztFQUNYLElBQUk7SUFDRjtJQUNBO0lBQ0EsSUFBSSxDQUFDRCxNQUFNLEVBQUVELEdBQUcsQ0FBQ25JLFVBQVUsR0FBRyxJQUFJLENBQUM7SUFDbkNxSSxFQUFFLEdBQUczYyxRQUFRLENBQUMsVUFBVSxFQUFFRCxXQUFXLENBQUM2YyxRQUFRLEdBQUc3YyxXQUFXLENBQUM4YyxVQUFVLENBQUM7SUFDeEUsTUFBTUMsR0FBRyxHQUFHVCxNQUFNLENBQUNVLEtBQUssQ0FBQyxJQUFJLENBQUM7SUFDOUIsS0FBSyxJQUFJQyxDQUFDLEdBQUcsQ0FBQyxFQUFFQSxDQUFDLEdBQUcsRUFBRSxFQUFFQSxDQUFDLEVBQUUsRUFBRTtNQUMzQixJQUFJL2MsUUFBUSxDQUFDMGMsRUFBRSxFQUFFRyxHQUFHLEVBQUUsQ0FBQyxFQUFFQSxHQUFHLENBQUM5TCxNQUFNLEVBQUUsSUFBSSxDQUFDLElBQUksQ0FBQyxFQUFFO0lBQ25EO0VBQ0YsQ0FBQyxDQUFDLE1BQU07SUFDTjtJQUNBO0VBQUEsQ0FDRCxTQUFTO0lBQ1IsSUFBSTJMLEVBQUUsSUFBSSxDQUFDLEVBQUU7TUFDWCxJQUFJO1FBQ0Y5YyxTQUFTLENBQUM4YyxFQUFFLENBQUM7TUFDZixDQUFDLENBQUMsTUFBTTtRQUNOO01BQUE7SUFFSjtJQUNBLElBQUksQ0FBQ0QsTUFBTSxFQUFFO01BQ1gsSUFBSTtRQUNGRCxHQUFHLENBQUNuSSxVQUFVLEdBQUcsS0FBSyxDQUFDO01BQ3pCLENBQUMsQ0FBQyxNQUFNO1FBQ047TUFBQTtJQUVKO0VBQ0Y7QUFDRjtBQUNBOztBQUVBLE1BQU0rRyxzQkFBc0IsR0FBRyxDQUM3QixLQUFLLEVBQ0wsTUFBTSxFQUNOLE9BQU8sRUFDUCxLQUFLLEVBQ0wsUUFBUSxFQUNSLE9BQU8sRUFDUCxZQUFZLEVBQ1osT0FBTyxFQUNQLGdCQUFnQixFQUNoQixVQUFVLEVBQ1YsT0FBTyxFQUNQLE1BQU0sRUFDTixTQUFTLEVBQ1QsU0FBUyxDQUNWLElBQUl4VSxLQUFLO0FBQ1YsTUFBTXlVLHNCQUFzQixHQUFHLENBQUMsTUFBTSxFQUFFLE9BQU8sRUFBRSxPQUFPLENBQUMsSUFBSXpVLEtBQUsiLCJpZ25vcmVMaXN0IjpbXX0= diff --git a/ui-tui/packages/hermes-ink/src/ink/instances.ts b/ui-tui/packages/hermes-ink/src/ink/instances.ts new file mode 100644 index 0000000000..389384a8d4 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/instances.ts @@ -0,0 +1,10 @@ +// Store all instances of Ink (instance.js) to ensure that consecutive render() calls +// use the same instance of Ink and don't create a new one +// +// This map has to be stored in a separate file, because render.js creates instances, +// but instance.js should delete itself from the map on unmount + +import type Ink from './ink.js' + +const instances = new Map() +export default instances diff --git a/ui-tui/packages/hermes-ink/src/ink/layout/engine.ts b/ui-tui/packages/hermes-ink/src/ink/layout/engine.ts new file mode 100644 index 0000000000..38f6dcb0fb --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/layout/engine.ts @@ -0,0 +1,6 @@ +import type { LayoutNode } from './node.js' +import { createYogaLayoutNode } from './yoga.js' + +export function createLayoutNode(): LayoutNode { + return createYogaLayoutNode() +} diff --git a/ui-tui/packages/hermes-ink/src/ink/layout/geometry.ts b/ui-tui/packages/hermes-ink/src/ink/layout/geometry.ts new file mode 100644 index 0000000000..871db1bc27 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/layout/geometry.ts @@ -0,0 +1,98 @@ +export type Point = { + x: number + y: number +} + +export type Size = { + width: number + height: number +} + +export type Rectangle = Point & Size + +/** Edge insets (padding, margin, border) */ +export type Edges = { + top: number + right: number + bottom: number + left: number +} + +/** Create uniform edges */ +export function edges(all: number): Edges +export function edges(vertical: number, horizontal: number): Edges +export function edges(top: number, right: number, bottom: number, left: number): Edges + +export function edges(a: number, b?: number, c?: number, d?: number): Edges { + if (b === undefined) { + return { top: a, right: a, bottom: a, left: a } + } + + if (c === undefined) { + return { top: a, right: b, bottom: a, left: b } + } + + return { top: a, right: b, bottom: c, left: d! } +} + +/** Add two edge values */ +export function addEdges(a: Edges, b: Edges): Edges { + return { + top: a.top + b.top, + right: a.right + b.right, + bottom: a.bottom + b.bottom, + left: a.left + b.left + } +} + +/** Zero edges constant */ +export const ZERO_EDGES: Edges = { top: 0, right: 0, bottom: 0, left: 0 } + +/** Convert partial edges to full edges with defaults */ +export function resolveEdges(partial?: Partial): Edges { + return { + top: partial?.top ?? 0, + right: partial?.right ?? 0, + bottom: partial?.bottom ?? 0, + left: partial?.left ?? 0 + } +} + +export function unionRect(a: Rectangle, b: Rectangle): Rectangle { + const minX = Math.min(a.x, b.x) + const minY = Math.min(a.y, b.y) + const maxX = Math.max(a.x + a.width, b.x + b.width) + const maxY = Math.max(a.y + a.height, b.y + b.height) + + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } +} + +export function clampRect(rect: Rectangle, size: Size): Rectangle { + const minX = Math.max(0, rect.x) + const minY = Math.max(0, rect.y) + const maxX = Math.min(size.width - 1, rect.x + rect.width - 1) + const maxY = Math.min(size.height - 1, rect.y + rect.height - 1) + + return { + x: minX, + y: minY, + width: Math.max(0, maxX - minX + 1), + height: Math.max(0, maxY - minY + 1) + } +} + +export function withinBounds(size: Size, point: Point): boolean { + return point.x >= 0 && point.y >= 0 && point.x < size.width && point.y < size.height +} + +export function clamp(value: number, min?: number, max?: number): number { + if (min !== undefined && value < min) { + return min + } + + if (max !== undefined && value > max) { + return max + } + + return value +} diff --git a/ui-tui/packages/hermes-ink/src/ink/layout/node.ts b/ui-tui/packages/hermes-ink/src/ink/layout/node.ts new file mode 100644 index 0000000000..fa84a4f810 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/layout/node.ts @@ -0,0 +1,145 @@ +// -- +// Adapter interface for the layout engine (Yoga) + +export const LayoutEdge = { + All: 'all', + Horizontal: 'horizontal', + Vertical: 'vertical', + Left: 'left', + Right: 'right', + Top: 'top', + Bottom: 'bottom', + Start: 'start', + End: 'end' +} as const +export type LayoutEdge = (typeof LayoutEdge)[keyof typeof LayoutEdge] + +export const LayoutGutter = { + All: 'all', + Column: 'column', + Row: 'row' +} as const +export type LayoutGutter = (typeof LayoutGutter)[keyof typeof LayoutGutter] + +export const LayoutDisplay = { + Flex: 'flex', + None: 'none' +} as const +export type LayoutDisplay = (typeof LayoutDisplay)[keyof typeof LayoutDisplay] + +export const LayoutFlexDirection = { + Row: 'row', + RowReverse: 'row-reverse', + Column: 'column', + ColumnReverse: 'column-reverse' +} as const +export type LayoutFlexDirection = (typeof LayoutFlexDirection)[keyof typeof LayoutFlexDirection] + +export const LayoutAlign = { + Auto: 'auto', + Stretch: 'stretch', + FlexStart: 'flex-start', + Center: 'center', + FlexEnd: 'flex-end' +} as const +export type LayoutAlign = (typeof LayoutAlign)[keyof typeof LayoutAlign] + +export const LayoutJustify = { + FlexStart: 'flex-start', + Center: 'center', + FlexEnd: 'flex-end', + SpaceBetween: 'space-between', + SpaceAround: 'space-around', + SpaceEvenly: 'space-evenly' +} as const +export type LayoutJustify = (typeof LayoutJustify)[keyof typeof LayoutJustify] + +export const LayoutWrap = { + NoWrap: 'nowrap', + Wrap: 'wrap', + WrapReverse: 'wrap-reverse' +} as const +export type LayoutWrap = (typeof LayoutWrap)[keyof typeof LayoutWrap] + +export const LayoutPositionType = { + Relative: 'relative', + Absolute: 'absolute' +} as const +export type LayoutPositionType = (typeof LayoutPositionType)[keyof typeof LayoutPositionType] + +export const LayoutOverflow = { + Visible: 'visible', + Hidden: 'hidden', + Scroll: 'scroll' +} as const +export type LayoutOverflow = (typeof LayoutOverflow)[keyof typeof LayoutOverflow] + +export type LayoutMeasureFunc = (width: number, widthMode: LayoutMeasureMode) => { width: number; height: number } + +export const LayoutMeasureMode = { + Undefined: 'undefined', + Exactly: 'exactly', + AtMost: 'at-most' +} as const +export type LayoutMeasureMode = (typeof LayoutMeasureMode)[keyof typeof LayoutMeasureMode] + +export type LayoutNode = { + // Tree + insertChild(child: LayoutNode, index: number): void + removeChild(child: LayoutNode): void + getChildCount(): number + getParent(): LayoutNode | null + + // Layout computation + calculateLayout(width?: number, height?: number): void + setMeasureFunc(fn: LayoutMeasureFunc): void + unsetMeasureFunc(): void + markDirty(): void + + // Layout reading (post-layout) + getComputedLeft(): number + getComputedTop(): number + getComputedWidth(): number + getComputedHeight(): number + getComputedBorder(edge: LayoutEdge): number + getComputedPadding(edge: LayoutEdge): number + + // Style setters + setWidth(value: number): void + setWidthPercent(value: number): void + setWidthAuto(): void + setHeight(value: number): void + setHeightPercent(value: number): void + setHeightAuto(): void + setMinWidth(value: number): void + setMinWidthPercent(value: number): void + setMinHeight(value: number): void + setMinHeightPercent(value: number): void + setMaxWidth(value: number): void + setMaxWidthPercent(value: number): void + setMaxHeight(value: number): void + setMaxHeightPercent(value: number): void + setFlexDirection(dir: LayoutFlexDirection): void + setFlexGrow(value: number): void + setFlexShrink(value: number): void + setFlexBasis(value: number): void + setFlexBasisPercent(value: number): void + setFlexWrap(wrap: LayoutWrap): void + setAlignItems(align: LayoutAlign): void + setAlignSelf(align: LayoutAlign): void + setJustifyContent(justify: LayoutJustify): void + setDisplay(display: LayoutDisplay): void + getDisplay(): LayoutDisplay + setPositionType(type: LayoutPositionType): void + setPosition(edge: LayoutEdge, value: number): void + setPositionPercent(edge: LayoutEdge, value: number): void + setOverflow(overflow: LayoutOverflow): void + setMargin(edge: LayoutEdge, value: number): void + setPadding(edge: LayoutEdge, value: number): void + setBorder(edge: LayoutEdge, value: number): void + setGap(gutter: LayoutGutter, value: number): void + + // Lifecycle + free(): void + freeRecursive(): void +} diff --git a/ui-tui/packages/hermes-ink/src/ink/layout/yoga.ts b/ui-tui/packages/hermes-ink/src/ink/layout/yoga.ts new file mode 100644 index 0000000000..e18c7f848c --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/layout/yoga.ts @@ -0,0 +1,313 @@ +import Yoga, { + Align, + Direction, + Display, + Edge, + FlexDirection, + Gutter, + Justify, + MeasureMode, + Overflow, + PositionType, + Wrap, + type Node as YogaNode +} from '../../native-ts/yoga-layout/index.js' + +import { + type LayoutAlign, + LayoutDisplay, + type LayoutEdge, + type LayoutFlexDirection, + type LayoutGutter, + type LayoutJustify, + type LayoutMeasureFunc, + LayoutMeasureMode, + type LayoutNode, + type LayoutOverflow, + type LayoutPositionType, + type LayoutWrap +} from './node.js' + +// -- +// Edge/Gutter mapping + +const EDGE_MAP: Record = { + all: Edge.All, + horizontal: Edge.Horizontal, + vertical: Edge.Vertical, + left: Edge.Left, + right: Edge.Right, + top: Edge.Top, + bottom: Edge.Bottom, + start: Edge.Start, + end: Edge.End +} + +const GUTTER_MAP: Record = { + all: Gutter.All, + column: Gutter.Column, + row: Gutter.Row +} + +// -- +// Yoga adapter + +export class YogaLayoutNode implements LayoutNode { + readonly yoga: YogaNode + + constructor(yoga: YogaNode) { + this.yoga = yoga + } + + // Tree + + insertChild(child: LayoutNode, index: number): void { + this.yoga.insertChild((child as YogaLayoutNode).yoga, index) + } + + removeChild(child: LayoutNode): void { + this.yoga.removeChild((child as YogaLayoutNode).yoga) + } + + getChildCount(): number { + return this.yoga.getChildCount() + } + + getParent(): LayoutNode | null { + const p = this.yoga.getParent() + + return p ? new YogaLayoutNode(p) : null + } + + // Layout + + calculateLayout(width?: number, _height?: number): void { + this.yoga.calculateLayout(width, undefined, Direction.LTR) + } + + setMeasureFunc(fn: LayoutMeasureFunc): void { + this.yoga.setMeasureFunc((w, wMode) => { + const mode = + wMode === MeasureMode.Exactly + ? LayoutMeasureMode.Exactly + : wMode === MeasureMode.AtMost + ? LayoutMeasureMode.AtMost + : LayoutMeasureMode.Undefined + + return fn(w, mode) + }) + } + + unsetMeasureFunc(): void { + this.yoga.unsetMeasureFunc() + } + + markDirty(): void { + this.yoga.markDirty() + } + + // Computed layout + + getComputedLeft(): number { + return this.yoga.getComputedLeft() + } + + getComputedTop(): number { + return this.yoga.getComputedTop() + } + + getComputedWidth(): number { + return this.yoga.getComputedWidth() + } + + getComputedHeight(): number { + return this.yoga.getComputedHeight() + } + + getComputedBorder(edge: LayoutEdge): number { + return this.yoga.getComputedBorder(EDGE_MAP[edge]!) + } + + getComputedPadding(edge: LayoutEdge): number { + return this.yoga.getComputedPadding(EDGE_MAP[edge]!) + } + + // Style setters + + setWidth(value: number): void { + this.yoga.setWidth(value) + } + setWidthPercent(value: number): void { + this.yoga.setWidthPercent(value) + } + setWidthAuto(): void { + this.yoga.setWidthAuto() + } + setHeight(value: number): void { + this.yoga.setHeight(value) + } + setHeightPercent(value: number): void { + this.yoga.setHeightPercent(value) + } + setHeightAuto(): void { + this.yoga.setHeightAuto() + } + setMinWidth(value: number): void { + this.yoga.setMinWidth(value) + } + setMinWidthPercent(value: number): void { + this.yoga.setMinWidthPercent(value) + } + setMinHeight(value: number): void { + this.yoga.setMinHeight(value) + } + setMinHeightPercent(value: number): void { + this.yoga.setMinHeightPercent(value) + } + setMaxWidth(value: number): void { + this.yoga.setMaxWidth(value) + } + setMaxWidthPercent(value: number): void { + this.yoga.setMaxWidthPercent(value) + } + setMaxHeight(value: number): void { + this.yoga.setMaxHeight(value) + } + setMaxHeightPercent(value: number): void { + this.yoga.setMaxHeightPercent(value) + } + + setFlexDirection(dir: LayoutFlexDirection): void { + const map: Record = { + row: FlexDirection.Row, + 'row-reverse': FlexDirection.RowReverse, + column: FlexDirection.Column, + 'column-reverse': FlexDirection.ColumnReverse + } + + this.yoga.setFlexDirection(map[dir]!) + } + + setFlexGrow(value: number): void { + this.yoga.setFlexGrow(value) + } + setFlexShrink(value: number): void { + this.yoga.setFlexShrink(value) + } + setFlexBasis(value: number): void { + this.yoga.setFlexBasis(value) + } + setFlexBasisPercent(value: number): void { + this.yoga.setFlexBasisPercent(value) + } + + setFlexWrap(wrap: LayoutWrap): void { + const map: Record = { + nowrap: Wrap.NoWrap, + wrap: Wrap.Wrap, + 'wrap-reverse': Wrap.WrapReverse + } + + this.yoga.setFlexWrap(map[wrap]!) + } + + setAlignItems(align: LayoutAlign): void { + const map: Record = { + auto: Align.Auto, + stretch: Align.Stretch, + 'flex-start': Align.FlexStart, + center: Align.Center, + 'flex-end': Align.FlexEnd + } + + this.yoga.setAlignItems(map[align]!) + } + + setAlignSelf(align: LayoutAlign): void { + const map: Record = { + auto: Align.Auto, + stretch: Align.Stretch, + 'flex-start': Align.FlexStart, + center: Align.Center, + 'flex-end': Align.FlexEnd + } + + this.yoga.setAlignSelf(map[align]!) + } + + setJustifyContent(justify: LayoutJustify): void { + const map: Record = { + 'flex-start': Justify.FlexStart, + center: Justify.Center, + 'flex-end': Justify.FlexEnd, + 'space-between': Justify.SpaceBetween, + 'space-around': Justify.SpaceAround, + 'space-evenly': Justify.SpaceEvenly + } + + this.yoga.setJustifyContent(map[justify]!) + } + + setDisplay(display: LayoutDisplay): void { + this.yoga.setDisplay(display === 'flex' ? Display.Flex : Display.None) + } + + getDisplay(): LayoutDisplay { + return this.yoga.getDisplay() === Display.None ? LayoutDisplay.None : LayoutDisplay.Flex + } + + setPositionType(type: LayoutPositionType): void { + this.yoga.setPositionType(type === 'absolute' ? PositionType.Absolute : PositionType.Relative) + } + + setPosition(edge: LayoutEdge, value: number): void { + this.yoga.setPosition(EDGE_MAP[edge]!, value) + } + + setPositionPercent(edge: LayoutEdge, value: number): void { + this.yoga.setPositionPercent(EDGE_MAP[edge]!, value) + } + + setOverflow(overflow: LayoutOverflow): void { + const map: Record = { + visible: Overflow.Visible, + hidden: Overflow.Hidden, + scroll: Overflow.Scroll + } + + this.yoga.setOverflow(map[overflow]!) + } + + setMargin(edge: LayoutEdge, value: number): void { + this.yoga.setMargin(EDGE_MAP[edge]!, value) + } + setPadding(edge: LayoutEdge, value: number): void { + this.yoga.setPadding(EDGE_MAP[edge]!, value) + } + setBorder(edge: LayoutEdge, value: number): void { + this.yoga.setBorder(EDGE_MAP[edge]!, value) + } + setGap(gutter: LayoutGutter, value: number): void { + this.yoga.setGap(GUTTER_MAP[gutter]!, value) + } + + // Lifecycle + + free(): void { + this.yoga.free() + } + freeRecursive(): void { + this.yoga.freeRecursive() + } +} + +// -- +// Instance management +// +// The TS yoga-layout port is synchronous — no WASM loading, no linear memory +// growth, so no preload/swap/reset machinery is needed. The Yoga instance is +// just a plain JS object available at import time. + +export function createYogaLayoutNode(): LayoutNode { + return new YogaLayoutNode(Yoga.Node.create()) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts b/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts new file mode 100644 index 0000000000..0791fbb8a6 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/line-width-cache.ts @@ -0,0 +1,28 @@ +import { stringWidth } from './stringWidth.js' + +// During streaming, text grows but completed lines are immutable. +// Caching stringWidth per-line avoids re-measuring hundreds of +// unchanged lines on every token (~50x reduction in stringWidth calls). +const cache = new Map() + +const MAX_CACHE_SIZE = 4096 + +export function lineWidth(line: string): number { + const cached = cache.get(line) + + if (cached !== undefined) { + return cached + } + + const width = stringWidth(line) + + // Evict when cache grows too large (e.g. after many different responses). + // Simple full-clear is fine — the cache repopulates in one frame. + if (cache.size >= MAX_CACHE_SIZE) { + cache.clear() + } + + cache.set(line, width) + + return width +} diff --git a/ui-tui/packages/hermes-ink/src/ink/log-update.ts b/ui-tui/packages/hermes-ink/src/ink/log-update.ts new file mode 100644 index 0000000000..e4dc3dc7a4 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/log-update.ts @@ -0,0 +1,738 @@ +import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize' + +import { logForDebugging } from '../utils/debug.js' + +import type { Diff, FlickerReason, Frame } from './frame.js' +import type { Point } from './layout/geometry.js' +import { + type Cell, + cellAt, + CellWidth, + charInCellAt, + diffEach, + type Hyperlink, + isEmptyCellAt, + type Screen, + shiftRows, + type StylePool, + visibleCellAtIndex +} from './screen.js' +import { + scrollDown as csiScrollDown, + scrollUp as csiScrollUp, + CURSOR_HOME, + RESET_SCROLL_REGION, + setScrollRegion +} from './termio/csi.js' +import { LINK_END, link as oscLink } from './termio/osc.js' + +type State = { + previousOutput: string +} + +type Options = { + isTTY: boolean + stylePool: StylePool +} + +const CARRIAGE_RETURN = { type: 'carriageReturn' } as const +const NEWLINE = { type: 'stdout', content: '\n' } as const + +export class LogUpdate { + private state: State + + constructor(private readonly options: Options) { + this.state = { + previousOutput: '' + } + } + + renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff { + if (!this.options.isTTY) { + // Non-TTY output is no longer supported (string output was removed) + return [NEWLINE] + } + + return this.getRenderOpsForDone(prevFrame) + } + + // Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content + reset(): void { + this.state.previousOutput = '' + } + + private renderFullFrame(frame: Frame): Diff { + const { screen } = frame + const lines: string[] = [] + let currentStyles: AnsiCode[] = [] + let currentHyperlink: Hyperlink = undefined + + for (let y = 0; y < screen.height; y++) { + let line = '' + + for (let x = 0; x < screen.width; x++) { + const cell = cellAt(screen, x, y) + + if (cell && cell.width !== CellWidth.SpacerTail) { + // Handle hyperlink transitions + if (cell.hyperlink !== currentHyperlink) { + if (currentHyperlink !== undefined) { + line += LINK_END + } + + if (cell.hyperlink !== undefined) { + line += oscLink(cell.hyperlink) + } + + currentHyperlink = cell.hyperlink + } + + const cellStyles = this.options.stylePool.get(cell.styleId) + const styleDiff = diffAnsiCodes(currentStyles, cellStyles) + + if (styleDiff.length > 0) { + line += ansiCodesToString(styleDiff) + currentStyles = cellStyles + } + + line += cell.char + } + } + + // Close any open hyperlink before resetting styles + if (currentHyperlink !== undefined) { + line += LINK_END + currentHyperlink = undefined + } + + // Reset styles at end of line so trimEnd doesn't leave dangling codes + const resetCodes = diffAnsiCodes(currentStyles, []) + + if (resetCodes.length > 0) { + line += ansiCodesToString(resetCodes) + currentStyles = [] + } + + lines.push(line.trimEnd()) + } + + if (lines.length === 0) { + return [] + } + + return [{ type: 'stdout', content: lines.join('\n') }] + } + + private getRenderOpsForDone(prev: Frame): Diff { + this.state.previousOutput = '' + + if (!prev.cursor.visible) { + return [{ type: 'cursorShow' }] + } + + return [] + } + + render(prev: Frame, next: Frame, altScreen = false, decstbmSafe = true): Diff { + if (!this.options.isTTY) { + return this.renderFullFrame(next) + } + + const startTime = performance.now() + const stylePool = this.options.stylePool + + // Since we assume the cursor is at the bottom on the screen, we only need + // to clear when the viewport gets shorter (i.e. the cursor position drifts) + // or when it gets thinner (and text wraps). We _could_ figure out how to + // not reset here but that would involve predicting the current layout + // _after_ the viewport change which means calcuating text wrapping. + // Resizing is a rare enough event that it's not practically a big issue. + if ( + next.viewport.height < prev.viewport.height || + (prev.viewport.width !== 0 && next.viewport.width !== prev.viewport.width) + ) { + return fullResetSequence_CAUSES_FLICKER(next, 'resize', stylePool) + } + + // DECSTBM scroll optimization: when a ScrollBox's scrollTop changed, + // shift content with a hardware scroll (CSI top;bot r + CSI n S/T) + // instead of rewriting the whole scroll region. The shiftRows on + // prev.screen simulates the shift so the diff loop below naturally + // finds only the rows that scrolled IN as diffs. prev.screen is + // about to become backFrame (reused next render) so mutation is safe. + // CURSOR_HOME after RESET_SCROLL_REGION is defensive — DECSTBM reset + // homes cursor per spec but terminal implementations vary. + // + // decstbmSafe: caller passes false when the DECSTBM→diff sequence + // can't be made atomic (no DEC 2026 / BSU/ESU). Without atomicity the + // outer terminal renders the intermediate state — region scrolled, + // edge rows not yet painted — a visible vertical jump on every frame + // where scrollTop moves. Falling through to the diff loop writes all + // shifted rows: more bytes, no intermediate state. next.screen from + // render-node-to-output's blit+shift is correct either way. + let scrollPatch: Diff = [] + + if (altScreen && next.scrollHint && decstbmSafe) { + const { top, bottom, delta } = next.scrollHint + + if (top >= 0 && bottom < prev.screen.height && bottom < next.screen.height) { + shiftRows(prev.screen, top, bottom, delta) + scrollPatch = [ + { + type: 'stdout', + content: + setScrollRegion(top + 1, bottom + 1) + + (delta > 0 ? csiScrollUp(delta) : csiScrollDown(-delta)) + + RESET_SCROLL_REGION + + CURSOR_HOME + } + ] + } + } + + // We have to use purely relative operations to manipulate the cursor since + // we don't know its starting point. + // + // When content height >= viewport height AND cursor is at the bottom, + // the cursor restore at the end of the previous frame caused terminal scroll. + // viewportY tells us how many rows are in scrollback from content overflow. + // Additionally, the cursor-restore scroll pushes 1 more row into scrollback. + // We need fullReset if any changes are to rows that are now in scrollback. + // + // This early full-reset check only applies in "steady state" (not growing). + // For growing, the viewportY calculation below (with cursorRestoreScroll) + // catches unreachable scrollback rows in the diff loop instead. + const cursorAtBottom = prev.cursor.y >= prev.screen.height + const isGrowing = next.screen.height > prev.screen.height + + // When content fills the viewport exactly (height == viewport) and the + // cursor is at the bottom, the cursor-restore LF at the end of the + // previous frame scrolled 1 row into scrollback. Use >= to catch this. + const prevHadScrollback = cursorAtBottom && prev.screen.height >= prev.viewport.height + + const isShrinking = next.screen.height < prev.screen.height + const nextFitsViewport = next.screen.height <= prev.viewport.height + + // When shrinking from above-viewport to at-or-below-viewport, content that + // was in scrollback should now be visible. Terminal clear operations can't + // bring scrollback content into view, so we need a full reset. + // Use <= (not <) because even when next height equals viewport height, the + // scrollback depth from the previous render differs from a fresh render. + if (prevHadScrollback && nextFitsViewport && isShrinking) { + logForDebugging( + `Full reset (shrink->below): prevHeight=${prev.screen.height}, nextHeight=${next.screen.height}, viewport=${prev.viewport.height}` + ) + + return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool) + } + + if (prev.screen.height >= prev.viewport.height && prev.screen.height > 0 && cursorAtBottom && !isGrowing) { + // viewportY = rows in scrollback from content overflow + // +1 for the row pushed by cursor-restore scroll + const viewportY = prev.screen.height - prev.viewport.height + const scrollbackRows = viewportY + 1 + + let scrollbackChangeY = -1 + diffEach(prev.screen, next.screen, (_x, y) => { + if (y < scrollbackRows) { + scrollbackChangeY = y + + return true // early exit + } + }) + + if (scrollbackChangeY >= 0) { + const prevLine = readLine(prev.screen, scrollbackChangeY) + const nextLine = readLine(next.screen, scrollbackChangeY) + + return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, { + triggerY: scrollbackChangeY, + prevLine, + nextLine + }) + } + } + + const screen = new VirtualScreen(prev.cursor, next.viewport.width) + + // Treat empty screen as height 1 to avoid spurious adjustments on first render + const heightDelta = Math.max(next.screen.height, 1) - Math.max(prev.screen.height, 1) + + const shrinking = heightDelta < 0 + const growing = heightDelta > 0 + + // Handle shrinking: clear lines from the bottom + if (shrinking) { + const linesToClear = prev.screen.height - next.screen.height + + // eraseLines only works within the viewport - it can't clear scrollback. + // If we need to clear more lines than fit in the viewport, some are in + // scrollback, so we need a full reset. + if (linesToClear > prev.viewport.height) { + return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', this.options.stylePool) + } + + // clear(N) moves cursor UP by N-1 lines and to column 0 + // This puts us at line prev.screen.height - N = next.screen.height + // But we want to be at next.screen.height - 1 (bottom of new screen) + screen.txn(prev => [ + [ + { type: 'clear', count: linesToClear }, + { type: 'cursorMove', x: 0, y: -1 } + ], + { dx: -prev.x, dy: -linesToClear } + ]) + } + + // viewportY = number of rows in scrollback (not visible on terminal). + // For shrinking: use max(prev, next) because terminal clears don't scroll. + // For growing: use prev state because new rows haven't scrolled old ones yet. + // When prevHadScrollback, add 1 for the cursor-restore LF that scrolled + // an additional row out of view at the end of the previous frame. Without + // this, the diff loop treats that row as reachable — but the cursor clamps + // at viewport top, causing writes to land 1 row off and garbling the output. + const cursorRestoreScroll = prevHadScrollback ? 1 : 0 + + const viewportY = growing + ? Math.max(0, prev.screen.height - prev.viewport.height + cursorRestoreScroll) + : Math.max(prev.screen.height, next.screen.height) - next.viewport.height + cursorRestoreScroll + + let currentStyleId = stylePool.none + let currentHyperlink: Hyperlink = undefined + + // First pass: render changes to existing rows (rows < prev.screen.height) + let needsFullReset = false + let resetTriggerY = -1 + diffEach(prev.screen, next.screen, (x, y, removed, added) => { + // Skip new rows - we'll render them directly after + if (growing && y >= prev.screen.height) { + return + } + + // Skip spacers during rendering because the terminal will automatically + // advance 2 columns when we write the wide character itself. + // SpacerTail: Second cell of a wide character + // SpacerHead: Marks line-end position where wide char wraps to next line + if (added && (added.width === CellWidth.SpacerTail || added.width === CellWidth.SpacerHead)) { + return + } + + if (removed && (removed.width === CellWidth.SpacerTail || removed.width === CellWidth.SpacerHead) && !added) { + return + } + + // Skip empty cells that don't need to overwrite existing content. + // This prevents writing trailing spaces that would cause unnecessary + // line wrapping at the edge of the screen. + // Uses isEmptyCellAt to check if both packed words are zero (empty cell). + if (added && isEmptyCellAt(next.screen, x, y) && !removed) { + return + } + + // If the cell outside the viewport range has changed, we need to reset + // because we can't move the cursor there to draw. + if (y < viewportY) { + needsFullReset = true + resetTriggerY = y + + return true // early exit + } + + moveCursorTo(screen, x, y) + + if (added) { + const targetHyperlink = added.hyperlink + currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink) + const styleStr = stylePool.transition(currentStyleId, added.styleId) + + if (writeCellWithStyleStr(screen, added, styleStr)) { + currentStyleId = added.styleId + } + } else if (removed) { + // Cell was removed - clear it with a space + // (This handles shrinking content) + // Reset any active styles/hyperlinks first to avoid leaking into cleared cells + const styleIdToReset = currentStyleId + const hyperlinkToReset = currentHyperlink + currentStyleId = stylePool.none + currentHyperlink = undefined + + screen.txn(() => { + const patches: Diff = [] + transitionStyle(patches, stylePool, styleIdToReset, stylePool.none) + transitionHyperlink(patches, hyperlinkToReset, undefined) + patches.push({ type: 'stdout', content: ' ' }) + + return [patches, { dx: 1, dy: 0 }] + }) + } + }) + + if (needsFullReset) { + return fullResetSequence_CAUSES_FLICKER(next, 'offscreen', stylePool, { + triggerY: resetTriggerY, + prevLine: readLine(prev.screen, resetTriggerY), + nextLine: readLine(next.screen, resetTriggerY) + }) + } + + // Reset styles before rendering new rows (they'll set their own styles) + currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none) + currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined) + + // Handle growth: render new rows directly (they naturally scroll the terminal) + if (growing) { + renderFrameSlice(screen, next, prev.screen.height, next.screen.height, stylePool) + } + + // Restore cursor. Skipped in alt-screen: the cursor is hidden, its + // position only matters as the starting point for the NEXT frame's + // relative moves, and in alt-screen the next frame always begins with + // CSI H (see ink.tsx onRender) which resets to (0,0) regardless. This + // saves a CR + cursorMove round-trip (~6-10 bytes) every frame. + // + // Main screen: if cursor needs to be past the last line of content + // (typical: cursor.y = screen.height), emit \n to create that line + // since cursor movement can't create new lines. + if (altScreen) { + // no-op; next frame's CSI H anchors cursor + } else if (next.cursor.y >= next.screen.height) { + // Move to column 0 of current line, then emit newlines to reach target row + screen.txn(prev => { + const rowsToCreate = next.cursor.y - prev.y + + if (rowsToCreate > 0) { + // Use CR to resolve pending wrap (if any) without advancing + // to the next line, then LF to create each new row. + const patches: Diff = new Array(1 + rowsToCreate) + patches[0] = CARRIAGE_RETURN + + for (let i = 0; i < rowsToCreate; i++) { + patches[1 + i] = NEWLINE + } + + return [patches, { dx: -prev.x, dy: rowsToCreate }] + } + + // At or past target row - need to move cursor to correct position + const dy = next.cursor.y - prev.y + + if (dy !== 0 || prev.x !== next.cursor.x) { + // Use CR to clear pending wrap (if any), then cursor move + const patches: Diff = [CARRIAGE_RETURN] + patches.push({ type: 'cursorMove', x: next.cursor.x, y: dy }) + + return [patches, { dx: next.cursor.x - prev.x, dy }] + } + + return [[], { dx: 0, dy: 0 }] + }) + } else { + moveCursorTo(screen, next.cursor.x, next.cursor.y) + } + + const elapsed = performance.now() - startTime + + if (elapsed > 50) { + const damage = next.screen.damage + + const damageInfo = damage ? `${damage.width}x${damage.height} at (${damage.x},${damage.y})` : 'none' + + logForDebugging( + `Slow render: ${elapsed.toFixed(1)}ms, screen: ${next.screen.height}x${next.screen.width}, damage: ${damageInfo}, changes: ${screen.diff.length}` + ) + } + + return scrollPatch.length > 0 ? [...scrollPatch, ...screen.diff] : screen.diff + } +} + +function transitionHyperlink(diff: Diff, current: Hyperlink, target: Hyperlink): Hyperlink { + if (current !== target) { + diff.push({ type: 'hyperlink', uri: target ?? '' }) + + return target + } + + return current +} + +function transitionStyle(diff: Diff, stylePool: StylePool, currentId: number, targetId: number): number { + const str = stylePool.transition(currentId, targetId) + + if (str.length > 0) { + diff.push({ type: 'styleStr', str }) + } + + return targetId +} + +function readLine(screen: Screen, y: number): string { + let line = '' + + for (let x = 0; x < screen.width; x++) { + line += charInCellAt(screen, x, y) ?? ' ' + } + + return line.trimEnd() +} + +function fullResetSequence_CAUSES_FLICKER( + frame: Frame, + reason: FlickerReason, + stylePool: StylePool, + debug?: { triggerY: number; prevLine: string; nextLine: string } +): Diff { + // After clearTerminal, cursor is at (0, 0) + const screen = new VirtualScreen({ x: 0, y: 0 }, frame.viewport.width) + renderFrame(screen, frame, stylePool) + + return [{ type: 'clearTerminal', reason, debug }, ...screen.diff] +} + +function renderFrame(screen: VirtualScreen, frame: Frame, stylePool: StylePool): void { + renderFrameSlice(screen, frame, 0, frame.screen.height, stylePool) +} + +/** + * Render a slice of rows from the frame's screen. + * Each row is rendered followed by a newline. Cursor ends at (0, endY). + */ +function renderFrameSlice( + screen: VirtualScreen, + frame: Frame, + startY: number, + endY: number, + stylePool: StylePool +): VirtualScreen { + let currentStyleId = stylePool.none + let currentHyperlink: Hyperlink = undefined + // Track the styleId of the last rendered cell on this line (-1 if none). + // Passed to visibleCellAtIndex to enable fg-only space optimization. + let lastRenderedStyleId = -1 + + const { width: screenWidth, cells, charPool, hyperlinkPool } = frame.screen + + let index = startY * screenWidth + + for (let y = startY; y < endY; y += 1) { + // Advance cursor to this row using LF (not CSI CUD / cursor-down). + // CSI CUD stops at the viewport bottom margin and cannot scroll, + // but LF scrolls the viewport to create new lines. Without this, + // when the cursor is at the viewport bottom, moveCursorTo's + // cursor-down silently fails, creating a permanent off-by-one + // between the virtual cursor and the real terminal cursor. + if (screen.cursor.y < y) { + const rowsToAdvance = y - screen.cursor.y + screen.txn(prev => { + const patches: Diff = new Array(1 + rowsToAdvance) + patches[0] = CARRIAGE_RETURN + + for (let i = 0; i < rowsToAdvance; i++) { + patches[1 + i] = NEWLINE + } + + return [patches, { dx: -prev.x, dy: rowsToAdvance }] + }) + } + + // Reset at start of each line — no cell rendered yet + lastRenderedStyleId = -1 + + for (let x = 0; x < screenWidth; x += 1, index += 1) { + // Skip spacers, unstyled empty cells, and fg-only styled spaces that + // match the last rendered style (since cursor-forward produces identical + // visual result). visibleCellAtIndex handles the optimization internally + // to avoid allocating Cell objects for skipped cells. + const cell = visibleCellAtIndex(cells, charPool, hyperlinkPool, index, lastRenderedStyleId) + + if (!cell) { + continue + } + + moveCursorTo(screen, x, y) + + // Handle hyperlink + const targetHyperlink = cell.hyperlink + currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, targetHyperlink) + + // Style transition — cached string, zero allocations after warmup + const styleStr = stylePool.transition(currentStyleId, cell.styleId) + + if (writeCellWithStyleStr(screen, cell, styleStr)) { + currentStyleId = cell.styleId + lastRenderedStyleId = cell.styleId + } + } + + // Reset styles/hyperlinks before newline so background color doesn't + // bleed into the next line when the terminal scrolls. The old code + // reset implicitly by writing trailing unstyled spaces; now that we + // skip empty cells, we must reset explicitly. + currentStyleId = transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none) + currentHyperlink = transitionHyperlink(screen.diff, currentHyperlink, undefined) + // CR+LF at end of row — \r resets to column 0, \n moves to next line. + // Without \r, the terminal cursor stays at whatever column content ended + // (since we skip trailing spaces, this can be mid-row). + screen.txn(prev => [[CARRIAGE_RETURN, NEWLINE], { dx: -prev.x, dy: 1 }]) + } + + // Reset any open style/hyperlink at end of slice + transitionStyle(screen.diff, stylePool, currentStyleId, stylePool.none) + transitionHyperlink(screen.diff, currentHyperlink, undefined) + + return screen +} + +type Delta = { dx: number; dy: number } + +/** + * Write a cell with a pre-serialized style transition string (from + * StylePool.transition). Inlines the txn logic to avoid closure/tuple/delta + * allocations on every cell. + * + * Returns true if the cell was written, false if skipped (wide char at + * viewport edge). Callers MUST gate currentStyleId updates on this — when + * skipped, styleStr is never pushed and the terminal's style state is + * unchanged. Updating the virtual tracker anyway desyncs it from the + * terminal, and the next transition is computed from phantom state. + */ +function writeCellWithStyleStr(screen: VirtualScreen, cell: Cell, styleStr: string): boolean { + const cellWidth = cell.width === CellWidth.Wide ? 2 : 1 + const px = screen.cursor.x + const vw = screen.viewportWidth + + // Don't write wide chars that would cross the viewport edge. + // Single-codepoint chars (CJK) at vw-2 are safe; multi-codepoint + // graphemes (flags, ZWJ emoji) need stricter threshold. + if (cellWidth === 2 && px < vw) { + const threshold = cell.char.length > 2 ? vw : vw + 1 + + if (px + 2 >= threshold) { + return false + } + } + + const diff = screen.diff + + if (styleStr.length > 0) { + diff.push({ type: 'styleStr', str: styleStr }) + } + + const needsCompensation = cellWidth === 2 && needsWidthCompensation(cell.char) + + // On terminals with old wcwidth tables, a compensated emoji only advances + // the cursor 1 column, so the CHA below skips column x+1 without painting + // it. Write a styled space there first — on correct terminals the emoji + // glyph (width 2) overwrites it harmlessly; on old terminals it fills the + // gap with the emoji's background. Also clears any stale content at x+1. + // CHA is 1-based, so column px+1 (0-based) is CHA target px+2. + if (needsCompensation && px + 1 < vw) { + diff.push({ type: 'cursorTo', col: px + 2 }) + diff.push({ type: 'stdout', content: ' ' }) + diff.push({ type: 'cursorTo', col: px + 1 }) + } + + diff.push({ type: 'stdout', content: cell.char }) + + // Force terminal cursor to correct column after the emoji. + if (needsCompensation) { + diff.push({ type: 'cursorTo', col: px + cellWidth + 1 }) + } + + // Update cursor — mutate in place to avoid Point allocation + if (px >= vw) { + screen.cursor.x = cellWidth + screen.cursor.y++ + } else { + screen.cursor.x = px + cellWidth + } + + return true +} + +function moveCursorTo(screen: VirtualScreen, targetX: number, targetY: number) { + screen.txn(prev => { + const dx = targetX - prev.x + const dy = targetY - prev.y + const inPendingWrap = prev.x >= screen.viewportWidth + + // If we're in pending wrap state (cursor.x >= width), use CR + // to reset to column 0 on the current line without advancing + // to the next line, then issue the cursor movement. + if (inPendingWrap) { + return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }] + } + + // When moving to a different line, use carriage return (\r) to reset to + // column 0 first, then cursor move. + if (dy !== 0) { + return [[CARRIAGE_RETURN, { type: 'cursorMove', x: targetX, y: dy }], { dx, dy }] + } + + // Standard same-line cursor move + return [[{ type: 'cursorMove', x: dx, y: dy }], { dx, dy }] + }) +} + +/** + * Identify emoji where the terminal's wcwidth may disagree with Unicode. + * On terminals with correct tables, the CHA we emit is a harmless no-op. + * + * Two categories: + * 1. Newer emoji (Unicode 12.0+) missing from terminal wcwidth tables. + * 2. Text-by-default emoji + VS16 (U+FE0F): the base codepoint is width 1 + * in wcwidth, but VS16 triggers emoji presentation making it width 2. + * Examples: ⚔️ (U+2694), ☠️ (U+2620), ❤️ (U+2764). + */ +function needsWidthCompensation(char: string): boolean { + const cp = char.codePointAt(0) + + if (cp === undefined) { + return false + } + + // U+1FA70-U+1FAFF: Symbols and Pictographs Extended-A (Unicode 12.0-15.0) + // U+1FB00-U+1FBFF: Symbols for Legacy Computing (Unicode 13.0) + if ((cp >= 0x1fa70 && cp <= 0x1faff) || (cp >= 0x1fb00 && cp <= 0x1fbff)) { + return true + } + + // Text-by-default emoji with VS16: scan for U+FE0F in multi-codepoint + // graphemes. Single BMP chars (length 1) and surrogate pairs without VS16 + // skip this check. VS16 (0xFE0F) can't collide with surrogates (0xD800-0xDFFF). + if (char.length >= 2) { + for (let i = 0; i < char.length; i++) { + if (char.charCodeAt(i) === 0xfe0f) { + return true + } + } + } + + return false +} + +class VirtualScreen { + // Public for direct mutation by writeCellWithStyleStr (avoids txn overhead). + // File-private class — not exposed outside log-update.ts. + cursor: Point + diff: Diff = [] + + constructor( + origin: Point, + readonly viewportWidth: number + ) { + this.cursor = { ...origin } + } + + txn(fn: (prev: Point) => [patches: Diff, next: Delta]): void { + const [patches, next] = fn(this.cursor) + + for (const patch of patches) { + this.diff.push(patch) + } + + this.cursor.x += next.dx + this.cursor.y += next.dy + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/measure-element.ts b/ui-tui/packages/hermes-ink/src/ink/measure-element.ts new file mode 100644 index 0000000000..64124d6ec2 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/measure-element.ts @@ -0,0 +1,23 @@ +import type { DOMElement } from './dom.js' + +type Output = { + /** + * Element width. + */ + width: number + + /** + * Element height. + */ + height: number +} + +/** + * Measure the dimensions of a particular `` element. + */ +const measureElement = (node: DOMElement): Output => ({ + width: node.yogaNode?.getComputedWidth() ?? 0, + height: node.yogaNode?.getComputedHeight() ?? 0 +}) + +export default measureElement diff --git a/ui-tui/packages/hermes-ink/src/ink/measure-text.ts b/ui-tui/packages/hermes-ink/src/ink/measure-text.ts new file mode 100644 index 0000000000..1d81cdedea --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/measure-text.ts @@ -0,0 +1,50 @@ +import { lineWidth } from './line-width-cache.js' + +type Output = { + width: number + height: number +} + +// Single-pass measurement: computes both width and height in one +// iteration instead of two (widestLine + countVisualLines). +// Uses indexOf to avoid array allocation from split('\n'). +function measureText(text: string, maxWidth: number): Output { + if (text.length === 0) { + return { + width: 0, + height: 0 + } + } + + // Infinite or non-positive width means no wrapping — each line is one visual line. + // Must check before the loop since Math.ceil(w / Infinity) = 0. + const noWrap = maxWidth <= 0 || !Number.isFinite(maxWidth) + + let height = 0 + let width = 0 + let start = 0 + + while (start <= text.length) { + const end = text.indexOf('\n', start) + const line = end === -1 ? text.substring(start) : text.substring(start, end) + + const w = lineWidth(line) + width = Math.max(width, w) + + if (noWrap) { + height++ + } else { + height += w === 0 ? 1 : Math.ceil(w / maxWidth) + } + + if (end === -1) { + break + } + + start = end + 1 + } + + return { width, height } +} + +export default measureText diff --git a/ui-tui/packages/hermes-ink/src/ink/node-cache.ts b/ui-tui/packages/hermes-ink/src/ink/node-cache.ts new file mode 100644 index 0000000000..fe11e067ec --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/node-cache.ts @@ -0,0 +1,53 @@ +import type { DOMElement } from './dom.js' +import type { Rectangle } from './layout/geometry.js' + +/** + * Cached layout bounds for each rendered node (used for blit + clearing). + * `top` is the yoga-local getComputedTop() — stored so ScrollBox viewport + * culling can skip yoga reads for clean children whose position hasn't + * shifted (O(dirty) instead of O(mounted) first-pass). + */ +export type CachedLayout = { + x: number + y: number + width: number + height: number + top?: number +} + +export const nodeCache = new WeakMap() + +/** Rects of removed children that need clearing on next render */ +export const pendingClears = new WeakMap() + +/** + * Set when a pendingClear is added for an absolute-positioned node. + * Signals renderer to disable blit for the next frame: the removed node + * may have painted over non-siblings (e.g. an overlay over a ScrollBox + * earlier in tree order), so their blits from prevScreen would restore + * the overlay's pixels. Normal-flow removals are already handled by + * hasRemovedChild at the parent level; only absolute positioning paints + * cross-subtree. Reset at the start of each render. + */ +let absoluteNodeRemoved = false + +export function addPendingClear(parent: DOMElement, rect: Rectangle, isAbsolute: boolean): void { + const existing = pendingClears.get(parent) + + if (existing) { + existing.push(rect) + } else { + pendingClears.set(parent, [rect]) + } + + if (isAbsolute) { + absoluteNodeRemoved = true + } +} + +export function consumeAbsoluteRemovedFlag(): boolean { + const had = absoluteNodeRemoved + absoluteNodeRemoved = false + + return had +} diff --git a/ui-tui/packages/hermes-ink/src/ink/optimizer.ts b/ui-tui/packages/hermes-ink/src/ink/optimizer.ts new file mode 100644 index 0000000000..a4fd3812c7 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/optimizer.ts @@ -0,0 +1,99 @@ +import type { Diff } from './frame.js' + +/** + * Optimize a diff by applying all optimization rules in a single pass. + * This reduces the number of patches that need to be written to the terminal. + * + * Rules applied: + * - Remove empty stdout patches + * - Merge consecutive cursorMove patches + * - Remove no-op cursorMove (0,0) patches + * - Concat adjacent style patches (transition diffs — can't drop either) + * - Dedupe consecutive hyperlinks with same URI + * - Cancel cursor hide/show pairs + * - Remove clear patches with count 0 + */ +export function optimize(diff: Diff): Diff { + if (diff.length <= 1) { + return diff + } + + const result: Diff = [] + let len = 0 + + for (const patch of diff) { + const type = patch.type + + // Skip no-ops + if (type === 'stdout') { + if (patch.content === '') { + continue + } + } else if (type === 'cursorMove') { + if (patch.x === 0 && patch.y === 0) { + continue + } + } else if (type === 'clear') { + if (patch.count === 0) { + continue + } + } + + // Try to merge with previous patch + if (len > 0) { + const lastIdx = len - 1 + const last = result[lastIdx]! + const lastType = last.type + + // Merge consecutive cursorMove + if (type === 'cursorMove' && lastType === 'cursorMove') { + result[lastIdx] = { + type: 'cursorMove', + x: last.x + patch.x, + y: last.y + patch.y + } + + continue + } + + // Collapse consecutive cursorTo (only the last one matters) + if (type === 'cursorTo' && lastType === 'cursorTo') { + result[lastIdx] = patch + + continue + } + + // Concat adjacent style patches. styleStr is a transition diff + // (computed by diffAnsiCodes(from, to)), not a setter — dropping + // the first is only sound if its undo-codes are a subset of the + // second's, which is NOT guaranteed. e.g. [\e[49m, \e[2m]: dropping + // the bg reset leaks it into the next \e[2J/\e[2K via BCE. + if (type === 'styleStr' && lastType === 'styleStr') { + result[lastIdx] = { type: 'styleStr', str: last.str + patch.str } + + continue + } + + // Dedupe hyperlinks + if (type === 'hyperlink' && lastType === 'hyperlink' && patch.uri === last.uri) { + continue + } + + // Cancel cursor hide/show pairs + if ( + (type === 'cursorShow' && lastType === 'cursorHide') || + (type === 'cursorHide' && lastType === 'cursorShow') + ) { + result.pop() + len-- + + continue + } + } + + result.push(patch) + len++ + } + + return result +} diff --git a/ui-tui/packages/hermes-ink/src/ink/output.ts b/ui-tui/packages/hermes-ink/src/ink/output.ts new file mode 100644 index 0000000000..ab417fcaed --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/output.ts @@ -0,0 +1,808 @@ +import { type AnsiCode, type StyledChar, styledCharsFromTokens, tokenize } from '@alcalzone/ansi-tokenize' + +import { logForDebugging } from '../utils/debug.js' +import { getGraphemeSegmenter } from '../utils/intl.js' +import sliceAnsi from '../utils/sliceAnsi.js' + +import { reorderBidi } from './bidi.js' +import { type Rectangle, unionRect } from './layout/geometry.js' +import { + blitRegion, + CellWidth, + extractHyperlinkFromStyles, + filterOutHyperlinkStyles, + markNoSelectRegion, + OSC8_PREFIX, + resetScreen, + type Screen, + setCellAt, + shiftRows, + type StylePool +} from './screen.js' +import { stringWidth } from './stringWidth.js' +import { widestLine } from './widest-line.js' + +/** + * A grapheme cluster with precomputed terminal width, styleId, and hyperlink. + * Built once per unique line (cached via charCache), so the per-char hot loop + * is just property reads + setCellAt — no stringWidth, no style interning, + * no hyperlink extraction per frame. + * + * styleId is safe to cache: StylePool is session-lived (never reset). + * hyperlink is stored as a string (not interned ID) since hyperlinkPool + * resets every 5 min; setCellAt interns it per-frame (cheap Map.get). + */ +type ClusteredChar = { + value: string + width: number + styleId: number + hyperlink: string | undefined +} + +/** + * Collects write/blit/clear/clip operations from the render tree, then + * applies them to a Screen buffer in `get()`. The Screen is what gets + * diffed against the previous frame to produce terminal updates. + */ + +type Options = { + width: number + height: number + stylePool: StylePool + /** + * Screen to render into. Will be reset before use. + * For double-buffering, pass a reusable screen. Otherwise create a new one. + */ + screen: Screen +} + +export type Operation = + | WriteOperation + | ClipOperation + | UnclipOperation + | BlitOperation + | ClearOperation + | NoSelectOperation + | ShiftOperation + +type WriteOperation = { + type: 'write' + x: number + y: number + text: string + /** + * Per-line soft-wrap flags, parallel to text.split('\n'). softWrap[i]=true + * means line i is a continuation of line i-1 (the `\n` before it was + * inserted by word-wrap, not in the source). Index 0 is always false. + * Undefined means the producer didn't track wrapping (e.g. fills, + * raw-ansi) — the screen's per-row bitmap is left untouched. + */ + softWrap?: boolean[] +} + +type ClipOperation = { + type: 'clip' + clip: Clip +} + +export type Clip = { + x1: number | undefined + x2: number | undefined + y1: number | undefined + y2: number | undefined +} + +/** + * Intersect two clips. `undefined` on an axis means unbounded; the other + * clip's bound wins. If both are bounded, take the tighter constraint + * (max of mins, min of maxes). If the resulting region is empty + * (x1 >= x2 or y1 >= y2), writes clipped by it will be dropped. + */ +function intersectClip(parent: Clip | undefined, child: Clip): Clip { + if (!parent) { + return child + } + + return { + x1: maxDefined(parent.x1, child.x1), + x2: minDefined(parent.x2, child.x2), + y1: maxDefined(parent.y1, child.y1), + y2: minDefined(parent.y2, child.y2) + } +} + +function maxDefined(a: number | undefined, b: number | undefined): number | undefined { + if (a === undefined) { + return b + } + + if (b === undefined) { + return a + } + + return Math.max(a, b) +} + +function minDefined(a: number | undefined, b: number | undefined): number | undefined { + if (a === undefined) { + return b + } + + if (b === undefined) { + return a + } + + return Math.min(a, b) +} + +type UnclipOperation = { + type: 'unclip' +} + +type BlitOperation = { + type: 'blit' + src: Screen + x: number + y: number + width: number + height: number +} + +type ShiftOperation = { + type: 'shift' + top: number + bottom: number + n: number +} + +type ClearOperation = { + type: 'clear' + region: Rectangle + /** + * Set when the clear is for an absolute-positioned node's old bounds. + * Absolute nodes overlay normal-flow siblings, so their stale paint is + * what an earlier sibling's clean-subtree blit wrongly restores from + * prevScreen. Normal-flow siblings' clears don't have this problem — + * their old position can't have been painted on top of a sibling. + */ + fromAbsolute?: boolean +} + +type NoSelectOperation = { + type: 'noSelect' + region: Rectangle +} + +export default class Output { + width: number + height: number + private readonly stylePool: StylePool + private screen: Screen + + private readonly operations: Operation[] = [] + + private charCache: Map = new Map() + + constructor(options: Options) { + const { width, height, stylePool, screen } = options + + this.width = width + this.height = height + this.stylePool = stylePool + this.screen = screen + + resetScreen(screen, width, height) + } + + /** + * Reuse this Output for a new frame. Zeroes the screen buffer, clears + * the operation list (backing storage is retained), and caps charCache + * growth. Preserving charCache across frames is the main win — most + * lines don't change between renders, so tokenize + grapheme clustering + * becomes a cache hit. + */ + reset(width: number, height: number, screen: Screen): void { + this.width = width + this.height = height + this.screen = screen + this.operations.length = 0 + resetScreen(screen, width, height) + + if (this.charCache.size > 16384) { + this.charCache.clear() + } + } + + /** + * Copy cells from a source screen region (blit = block image transfer). + */ + blit(src: Screen, x: number, y: number, width: number, height: number): void { + this.operations.push({ type: 'blit', src, x, y, width, height }) + } + + /** + * Shift full-width rows within [top, bottom] by n. n > 0 = up. Mirrors + * what DECSTBM + SU/SD does to the terminal. Paired with blit() to reuse + * prevScreen content during pure scroll, avoiding full child re-render. + */ + shift(top: number, bottom: number, n: number): void { + this.operations.push({ type: 'shift', top, bottom, n }) + } + + /** + * Clear a region by writing empty cells. Used when a node shrinks to + * ensure stale content from the previous frame is removed. + */ + clear(region: Rectangle, fromAbsolute?: boolean): void { + this.operations.push({ type: 'clear', region, fromAbsolute }) + } + + /** + * Mark a region as non-selectable (excluded from fullscreen text + * selection copy + highlight). Used by to fence off + * gutters (line numbers, diff sigils). Applied AFTER blit/write so + * the mark wins regardless of what's blitted into the region. + */ + noSelect(region: Rectangle): void { + this.operations.push({ type: 'noSelect', region }) + } + + write(x: number, y: number, text: string, softWrap?: boolean[]): void { + if (!text) { + return + } + + this.operations.push({ + type: 'write', + x, + y, + text, + softWrap + }) + } + + clip(clip: Clip) { + this.operations.push({ + type: 'clip', + clip + }) + } + + unclip() { + this.operations.push({ + type: 'unclip' + }) + } + + get(): Screen { + const screen = this.screen + const screenWidth = this.width + const screenHeight = this.height + + // Track blit vs write cell counts for debugging + let blitCells = 0 + let writeCells = 0 + + // Pass 1: expand damage to cover clear regions. The buffer is freshly + // zeroed by resetScreen, so this pass only marks damage so diff() + // checks these regions against the previous frame. + // + // Also collect clears from absolute-positioned nodes. An absolute + // node overlays normal-flow siblings; when it shrinks, its clear is + // pushed AFTER those siblings' clean-subtree blits (DOM order). The + // blit copies the absolute node's own stale paint from prevScreen, + // and since clear is damage-only, the ghost survives diff. Normal- + // flow clears don't need this — a normal-flow node's old position + // can't have been painted on top of a sibling's current position. + const absoluteClears: Rectangle[] = [] + + for (const operation of this.operations) { + if (operation.type !== 'clear') { + continue + } + + const { x, y, width, height } = operation.region + const startX = Math.max(0, x) + const startY = Math.max(0, y) + const maxX = Math.min(x + width, screenWidth) + const maxY = Math.min(y + height, screenHeight) + + if (startX >= maxX || startY >= maxY) { + continue + } + + const rect = { + x: startX, + y: startY, + width: maxX - startX, + height: maxY - startY + } + + screen.damage = screen.damage ? unionRect(screen.damage, rect) : rect + + if (operation.fromAbsolute) { + absoluteClears.push(rect) + } + } + + const clips: Clip[] = [] + + for (const operation of this.operations) { + switch (operation.type) { + case 'clear': + // handled in pass 1 + continue + + case 'clip': + // Intersect with the parent clip (if any) so nested + // overflow:hidden boxes can't write outside their ancestor's + // clip region. Without this, a message with overflow:hidden at + // the bottom of a scrollbox pushes its OWN clip (based on its + // layout bounds, already translated by -scrollTop) which can + // extend below the scrollbox viewport — writes escape into + // the sibling bottom section's rows. + clips.push(intersectClip(clips.at(-1), operation.clip)) + + continue + + case 'unclip': + clips.pop() + + continue + case 'blit': { + // Bulk-copy cells from source screen region using TypedArray.set(). + // Tracking damage ensures diff() checks blitted cells for stale content + // when a parent blits an area that previously contained child content. + const { src, x: regionX, y: regionY, width: regionWidth, height: regionHeight } = operation + + // Intersect with active clip — a child's clean-blit passes its full + // cached rect, but the parent ScrollBox may have shrunk (pill mount). + // Without this, the blit writes past the ScrollBox's new bottom edge + // into the pill's row. + const clip = clips.at(-1) + const startX = Math.max(regionX, clip?.x1 ?? 0) + const startY = Math.max(regionY, clip?.y1 ?? 0) + + const maxY = Math.min(regionY + regionHeight, screenHeight, src.height, clip?.y2 ?? Infinity) + + const maxX = Math.min(regionX + regionWidth, screenWidth, src.width, clip?.x2 ?? Infinity) + + if (startX >= maxX || startY >= maxY) { + continue + } + + // Skip rows covered by an absolute-positioned node's clear. + // Absolute nodes overlay normal-flow siblings, so prevScreen in + // that region holds the absolute node's stale paint — blitting + // it back would ghost. See absoluteClears collection above. + if (absoluteClears.length === 0) { + blitRegion(screen, src, startX, startY, maxX, maxY) + blitCells += (maxY - startY) * (maxX - startX) + + continue + } + + let rowStart = startY + + for (let row = startY; row <= maxY; row++) { + const excluded = + row < maxY && + absoluteClears.some(r => row >= r.y && row < r.y + r.height && startX >= r.x && maxX <= r.x + r.width) + + if (excluded || row === maxY) { + if (row > rowStart) { + blitRegion(screen, src, startX, rowStart, maxX, row) + blitCells += (row - rowStart) * (maxX - startX) + } + + rowStart = row + 1 + } + } + + continue + } + + case 'shift': { + shiftRows(screen, operation.top, operation.bottom, operation.n) + + continue + } + + case 'write': { + const { text, softWrap } = operation + let { x, y } = operation + let lines = text.split('\n') + let swFrom = 0 + let prevContentEnd = 0 + + const clip = clips.at(-1) + + if (clip) { + const clipHorizontally = typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number' + + const clipVertically = typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number' + + // If text is positioned outside of clipping area altogether, + // skip to the next operation to avoid unnecessary calculations + if (clipHorizontally) { + const width = widestLine(text) + + if (x + width <= clip.x1! || x >= clip.x2!) { + continue + } + } + + if (clipVertically) { + const height = lines.length + + if (y + height <= clip.y1! || y >= clip.y2!) { + continue + } + } + + if (clipHorizontally) { + lines = lines.map(line => { + const from = x < clip.x1! ? clip.x1! - x : 0 + const width = stringWidth(line) + const to = x + width > clip.x2! ? clip.x2! - x : width + let sliced = sliceAnsi(line, from, to) + + // Wide chars (CJK, emoji) occupy 2 cells. When `to` lands + // on the first cell of a wide char, sliceAnsi includes the + // entire glyph and the result overflows clip.x2 by one cell, + // writing a SpacerTail into the adjacent sibling. Re-slice + // one cell earlier; wide chars are exactly 2 cells, so a + // single retry always fits. + if (stringWidth(sliced) > to - from) { + sliced = sliceAnsi(line, from, to - 1) + } + + return sliced + }) + + if (x < clip.x1!) { + x = clip.x1! + } + } + + if (clipVertically) { + const from = y < clip.y1! ? clip.y1! - y : 0 + const height = lines.length + const to = y + height > clip.y2! ? clip.y2! - y : height + + // If the first visible line is a soft-wrap continuation, we + // need the clipped previous line's content end so + // screen.softWrap[lineY] correctly records the join point + // even though that line's cells were never written. + if (softWrap && from > 0 && softWrap[from] === true) { + prevContentEnd = x + stringWidth(lines[from - 1]!) + } + + lines = lines.slice(from, to) + swFrom = from + + if (y < clip.y1!) { + y = clip.y1! + } + } + } + + const swBits = screen.softWrap + let offsetY = 0 + + for (const line of lines) { + const lineY = y + offsetY + + // Line can be outside screen if `text` is taller than screen height + if (lineY >= screenHeight) { + break + } + + const contentEnd = writeLineToScreen(screen, line, x, lineY, screenWidth, this.stylePool, this.charCache) + + writeCells += contentEnd - x + + // See Screen.softWrap docstring for the encoding. contentEnd + // from writeLineToScreen is tab-expansion-aware, unlike + // x+stringWidth(line) which treats tabs as width 0. + if (softWrap) { + const isSW = softWrap[swFrom + offsetY] === true + swBits[lineY] = isSW ? prevContentEnd : 0 + prevContentEnd = contentEnd + } + + offsetY++ + } + + continue + } + } + } + + // noSelect ops go LAST so they win over blits (which copy noSelect + // from prevScreen) and writes (which don't touch noSelect). This way + // a box correctly fences its region even when the parent + // blits, and moving a between frames correctly clears the + // old region (resetScreen already zeroed the bitmap). + for (const operation of this.operations) { + if (operation.type === 'noSelect') { + const { x, y, width, height } = operation.region + markNoSelectRegion(screen, x, y, width, height) + } + } + + // Log blit/write ratio for debugging - high write count suggests blitting isn't working + const totalCells = blitCells + writeCells + + if (totalCells > 1000 && writeCells > blitCells) { + logForDebugging( + `High write ratio: blit=${blitCells}, write=${writeCells} (${((writeCells / totalCells) * 100).toFixed(1)}% writes), screen=${screenHeight}x${screenWidth}` + ) + } + + return screen + } +} + +function stylesEqual(a: AnsiCode[], b: AnsiCode[]): boolean { + if (a === b) { + return true + } // Reference equality fast path + + const len = a.length + + if (len !== b.length) { + return false + } + + if (len === 0) { + return true + } // Both empty + + for (let i = 0; i < len; i++) { + if (a[i]!.code !== b[i]!.code) { + return false + } + } + + return true +} + +/** + * Convert a string with ANSI codes into styled characters with proper grapheme + * clustering. Fixes ansi-tokenize splitting grapheme clusters (like family + * emojis) into individual code points. + * + * Also precomputes styleId + hyperlink per style run (not per char) — an + * 80-char line with 3 style runs does 3 intern calls instead of 80. + */ +function styledCharsWithGraphemeClustering(chars: StyledChar[], stylePool: StylePool): ClusteredChar[] { + const charCount = chars.length + + if (charCount === 0) { + return [] + } + + const result: ClusteredChar[] = [] + const bufferChars: string[] = [] + let bufferStyles: AnsiCode[] = chars[0]!.styles + + for (let i = 0; i < charCount; i++) { + const char = chars[i]! + const styles = char.styles + + // Different styles means we need to flush and start new buffer + if (bufferChars.length > 0 && !stylesEqual(styles, bufferStyles)) { + flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result) + bufferChars.length = 0 + } + + bufferChars.push(char.value) + bufferStyles = styles + } + + // Final flush + if (bufferChars.length > 0) { + flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result) + } + + return result +} + +function flushBuffer(buffer: string, styles: AnsiCode[], stylePool: StylePool, out: ClusteredChar[]): void { + // Compute styleId + hyperlink ONCE for the whole style run. + // Every grapheme in this buffer shares the same styles. + // + // Extract and track hyperlinks separately, filter from styles. + // Always check for OSC 8 codes to filter, not just when a URL is + // extracted. The tokenizer treats OSC 8 close codes (empty URL) as + // active styles, so they must be filtered even when no hyperlink + // URL is present. + const hyperlink = extractHyperlinkFromStyles(styles) ?? undefined + + const hasOsc8Styles = + hyperlink !== undefined || styles.some(s => s.code.length >= OSC8_PREFIX.length && s.code.startsWith(OSC8_PREFIX)) + + const filteredStyles = hasOsc8Styles ? filterOutHyperlinkStyles(styles) : styles + + const styleId = stylePool.intern(filteredStyles) + + for (const { segment: grapheme } of getGraphemeSegmenter().segment(buffer)) { + out.push({ + value: grapheme, + width: stringWidth(grapheme), + styleId, + hyperlink + }) + } +} + +/** + * Write a single line's characters into the screen buffer. + * Extracted from Output.get() so JSC can optimize this tight, + * monomorphic loop independently — better register allocation, + * setCellAt inlining, and type feedback than when buried inside + * a 300-line dispatch function. + * + * Returns the end column (x + visual width, including tab expansion) so + * the caller can record it in screen.softWrap without re-walking the + * line via stringWidth(). Caller computes the debug cell-count as end-x. + */ +function writeLineToScreen( + screen: Screen, + line: string, + x: number, + y: number, + screenWidth: number, + stylePool: StylePool, + charCache: Map +): number { + let characters = charCache.get(line) + + if (!characters) { + characters = reorderBidi(styledCharsWithGraphemeClustering(styledCharsFromTokens(tokenize(line)), stylePool)) + charCache.set(line, characters) + } + + let offsetX = x + + for (let charIdx = 0; charIdx < characters.length; charIdx++) { + const character = characters[charIdx]! + const codePoint = character.value.codePointAt(0) + + // Handle C0 control characters (0x00-0x1F) that cause cursor movement + // mismatches. stringWidth treats these as width 0, but terminals may + // move the cursor differently. + if (codePoint !== undefined && codePoint <= 0x1f) { + // Tab (0x09): expand to spaces to reach next tab stop + if (codePoint === 0x09) { + const tabWidth = 8 + const spacesToNextStop = tabWidth - (offsetX % tabWidth) + + for (let i = 0; i < spacesToNextStop && offsetX < screenWidth; i++) { + setCellAt(screen, offsetX, y, { + char: ' ', + styleId: stylePool.none, + width: CellWidth.Narrow, + hyperlink: undefined + }) + offsetX++ + } + } + // ESC (0x1B): skip incomplete escape sequences that ansi-tokenize + // didn't recognize. ansi-tokenize only parses SGR sequences (ESC[...m) + // and OSC 8 hyperlinks (ESC]8;;url BEL). Other sequences like cursor + // movement, screen clearing, or terminal title become individual char + // tokens that we need to skip here. + else if (codePoint === 0x1b) { + const nextChar = characters[charIdx + 1]?.value + const nextCode = nextChar?.codePointAt(0) + + if (nextChar === '(' || nextChar === ')' || nextChar === '*' || nextChar === '+') { + // Charset selection: ESC ( X, ESC ) X, etc. + // Skip the intermediate char and the charset designator + charIdx += 2 + } else if (nextChar === '[') { + // CSI sequence: ESC [ ... final-byte + // Final byte is in range 0x40-0x7E (@, A-Z, [\]^_`, a-z, {|}~) + // Examples: ESC[2J (clear), ESC[?25l (cursor hide), ESC[H (home) + charIdx++ // skip the [ + + while (charIdx < characters.length - 1) { + charIdx++ + const c = characters[charIdx]?.value.codePointAt(0) + + // Final byte terminates the sequence + if (c !== undefined && c >= 0x40 && c <= 0x7e) { + break + } + } + } else if (nextChar === ']' || nextChar === 'P' || nextChar === '_' || nextChar === '^' || nextChar === 'X') { + // String-based sequences terminated by BEL (0x07) or ST (ESC \): + // - OSC: ESC ] ... (Operating System Command) + // - DCS: ESC P ... (Device Control String) + // - APC: ESC _ ... (Application Program Command) + // - PM: ESC ^ ... (Privacy Message) + // - SOS: ESC X ... (Start of String) + charIdx++ // skip the introducer char + + while (charIdx < characters.length - 1) { + charIdx++ + const c = characters[charIdx]?.value + + // BEL (0x07) terminates the sequence + if (c === '\x07') { + break + } + + // ST (String Terminator) is ESC \ + // When we see ESC, check if next char is backslash + if (c === '\x1b') { + const nextC = characters[charIdx + 1]?.value + + if (nextC === '\\') { + charIdx++ // skip the backslash too + + break + } + } + } + } else if (nextCode !== undefined && nextCode >= 0x30 && nextCode <= 0x7e) { + // Single-character escape sequences: ESC followed by 0x30-0x7E + // (excluding the multi-char introducers already handled above) + // - Fp range (0x30-0x3F): ESC 7 (save cursor), ESC 8 (restore) + // - Fe range (0x40-0x5F): ESC D (index), ESC M (reverse index) + // - Fs range (0x60-0x7E): ESC c (reset) + charIdx++ // skip the command char + } + } + + // Carriage return (0x0D): would move cursor to column 0, skip it + // Backspace (0x08): would move cursor left, skip it + // Bell (0x07), vertical tab (0x0B), form feed (0x0C): skip + // All other control chars (0x00-0x06, 0x0E-0x1F): skip + // Note: newline (0x0A) is already handled by line splitting + continue + } + + // Zero-width characters (combining marks, ZWNJ, ZWS, etc.) + // don't occupy terminal cells — storing them as Narrow cells + // desyncs the virtual cursor from the real terminal cursor. + // Width was computed once during clustering (cached via charCache). + const charWidth = character.width + + if (charWidth === 0) { + continue + } + + const isWideCharacter = charWidth >= 2 + + // Wide char at last column can't fit — terminal would wrap it to + // the next line, desyncing our cursor model. Place a SpacerHead + // to mark the blank column, matching terminal behavior. + if (isWideCharacter && offsetX + 2 > screenWidth) { + setCellAt(screen, offsetX, y, { + char: ' ', + styleId: stylePool.none, + width: CellWidth.SpacerHead, + hyperlink: undefined + }) + offsetX++ + + continue + } + + // styleId + hyperlink were precomputed during clustering (once per + // style run, cached via charCache). Hot loop is now just property + // reads — no intern, no extract, no filter per frame. + setCellAt(screen, offsetX, y, { + char: character.value, + styleId: character.styleId, + width: isWideCharacter ? CellWidth.Wide : CellWidth.Narrow, + hyperlink: character.hyperlink + }) + offsetX += isWideCharacter ? 2 : 1 + } + + return offsetX +} diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts new file mode 100644 index 0000000000..7c795d1f0e --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -0,0 +1,833 @@ +/** + * Keyboard input parser - converts terminal input to key events + * + * Uses the termio tokenizer for escape sequence boundary detection, + * then interprets sequences as keypresses. + */ +import { Buffer } from 'buffer' + +import { PASTE_END, PASTE_START } from './termio/csi.js' +import { createTokenizer, type Tokenizer } from './termio/tokenize.js' + +// eslint-disable-next-line no-control-regex +const META_KEY_CODE_RE = /^(?:\x1b)([a-zA-Z0-9])$/ + +const FN_KEY_RE = + // eslint-disable-next-line no-control-regex + /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/ + +// CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u +// Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers) +// Modifier is optional - when absent, defaults to 1 (no modifiers) +// eslint-disable-next-line no-control-regex +const CSI_U_RE = /^\x1b\[(\d+)(?:;(\d+))?u/ + +// xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~ +// Example: ESC[27;2;13~ = Shift+Enter. Emitted by Ghostty/tmux/xterm when +// modifyOtherKeys=2 is active or via user keybinds, typically over SSH where +// TERM sniffing misses Ghostty and we never push Kitty keyboard mode. +// Note param order is reversed vs CSI u (modifier first, keycode second). +// eslint-disable-next-line no-control-regex +const MODIFY_OTHER_KEYS_RE = /^\x1b\[27;(\d+);(\d+)~/ + +// -- Terminal response patterns (inbound sequences from the terminal itself) -- +// DECRPM: CSI ? Ps ; Pm $ y — response to DECRQM (request mode) +// eslint-disable-next-line no-control-regex +const DECRPM_RE = /^\x1b\[\?(\d+);(\d+)\$y$/ +// DA1: CSI ? Ps ; ... c — primary device attributes response +// eslint-disable-next-line no-control-regex +const DA1_RE = /^\x1b\[\?([\d;]*)c$/ +// DA2: CSI > Ps ; ... c — secondary device attributes response +// eslint-disable-next-line no-control-regex +const DA2_RE = /^\x1b\[>([\d;]*)c$/ +// Kitty keyboard flags: CSI ? flags u — response to CSI ? u query +// (private ? marker distinguishes from CSI u key events) +// eslint-disable-next-line no-control-regex +const KITTY_FLAGS_RE = /^\x1b\[\?(\d+)u$/ +// DECXCPR cursor position: CSI ? row ; col R +// The ? marker disambiguates from modified F3 keys (Shift+F3 = CSI 1;2 R, +// Ctrl+F3 = CSI 1;5 R, etc.) — plain CSI row;col R is genuinely ambiguous. +// eslint-disable-next-line no-control-regex +const CURSOR_POSITION_RE = /^\x1b\[\?(\d+);(\d+)R$/ +// OSC response: OSC code ; data (BEL|ST) +// eslint-disable-next-line no-control-regex +const OSC_RESPONSE_RE = /^\x1b\](\d+);(.*?)(?:\x07|\x1b\\)$/s +// XTVERSION: DCS > | name ST — terminal name/version string (answer to CSI > 0 q). +// xterm.js replies "xterm.js(X.Y.Z)"; Ghostty, kitty, iTerm2, etc. reply with +// their own name. Unlike TERM_PROGRAM, this survives SSH since the query/reply +// goes through the pty, not the environment. +// eslint-disable-next-line no-control-regex +const XTVERSION_RE = /^\x1bP>\|(.*?)(?:\x07|\x1b\\)$/s +// SGR mouse event: CSI < button ; col ; row M (press) or m (release) +// Button codes: 64=wheel-up, 65=wheel-down (0x40 | wheel-bit). +// Button 32=left-drag (0x20 | motion-bit). Plain 0/1/2 = left/mid/right click. +// eslint-disable-next-line no-control-regex +const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/ + +function createPasteKey(content: string): ParsedKey { + return { + kind: 'key', + name: '', + fn: false, + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + sequence: content, + raw: content, + isPasted: true + } +} + +/** DECRPM status values (response to DECRQM) */ +export const DECRPM_STATUS = { + NOT_RECOGNIZED: 0, + SET: 1, + RESET: 2, + PERMANENTLY_SET: 3, + PERMANENTLY_RESET: 4 +} as const + +/** + * A response sequence received from the terminal (not a keypress). + * Emitted in answer to queries like DECRQM, DA1, OSC 11, etc. + */ +export type TerminalResponse = + /** DECRPM: answer to DECRQM (request DEC private mode status) */ + | { type: 'decrpm'; mode: number; status: number } + /** DA1: primary device attributes (used as a universal sentinel) */ + | { type: 'da1'; params: number[] } + /** DA2: secondary device attributes (terminal version info) */ + | { type: 'da2'; params: number[] } + /** Kitty keyboard protocol: current flags (answer to CSI ? u) */ + | { type: 'kittyKeyboard'; flags: number } + /** DSR: cursor position report (answer to CSI 6 n) */ + | { type: 'cursorPosition'; row: number; col: number } + /** OSC response: generic operating-system-command reply (e.g. OSC 11 bg color) */ + | { type: 'osc'; code: number; data: string } + /** XTVERSION: terminal name/version string (answer to CSI > 0 q). + * Example values: "xterm.js(5.5.0)", "ghostty 1.2.0", "iTerm2 3.6". */ + | { type: 'xtversion'; name: string } + +/** + * Try to recognize a sequence token as a terminal response. + * Returns null if the sequence is not a known response pattern + * (i.e. it should be treated as a keypress). + * + * These patterns are syntactically distinguishable from keyboard input — + * no physical key produces CSI ? ... c or CSI ? ... $ y, so they can be + * safely parsed out of the input stream at any time. + */ +function parseTerminalResponse(s: string): TerminalResponse | null { + // CSI-prefixed responses + if (s.startsWith('\x1b[')) { + let m: RegExpExecArray | null + + if ((m = DECRPM_RE.exec(s))) { + return { + type: 'decrpm', + mode: parseInt(m[1]!, 10), + status: parseInt(m[2]!, 10) + } + } + + if ((m = DA1_RE.exec(s))) { + return { type: 'da1', params: splitNumericParams(m[1]!) } + } + + if ((m = DA2_RE.exec(s))) { + return { type: 'da2', params: splitNumericParams(m[1]!) } + } + + if ((m = KITTY_FLAGS_RE.exec(s))) { + return { type: 'kittyKeyboard', flags: parseInt(m[1]!, 10) } + } + + if ((m = CURSOR_POSITION_RE.exec(s))) { + return { + type: 'cursorPosition', + row: parseInt(m[1]!, 10), + col: parseInt(m[2]!, 10) + } + } + + return null + } + + // OSC responses (e.g. OSC 11 ; rgb:... for bg color query) + if (s.startsWith('\x1b]')) { + const m = OSC_RESPONSE_RE.exec(s) + + if (m) { + return { type: 'osc', code: parseInt(m[1]!, 10), data: m[2]! } + } + } + + // DCS responses (e.g. XTVERSION: DCS > | name ST) + if (s.startsWith('\x1bP')) { + const m = XTVERSION_RE.exec(s) + + if (m) { + return { type: 'xtversion', name: m[1]! } + } + } + + return null +} + +function splitNumericParams(params: string): number[] { + if (!params) { + return [] + } + + return params.split(';').map(p => parseInt(p, 10)) +} + +export type KeyParseState = { + mode: 'NORMAL' | 'IN_PASTE' + incomplete: string + pasteBuffer: string + // Internal tokenizer instance + _tokenizer?: Tokenizer +} + +export const INITIAL_STATE: KeyParseState = { + mode: 'NORMAL', + incomplete: '', + pasteBuffer: '' +} + +function inputToString(input: Buffer | string): string { + if (Buffer.isBuffer(input)) { + if (input[0]! > 127 && input[1] === undefined) { + ;(input[0] as unknown as number) -= 128 + + return '\x1b' + String(input) + } else { + return String(input) + } + } else if (input !== undefined && typeof input !== 'string') { + return String(input) + } else if (!input) { + return '' + } else { + return input + } +} + +export function parseMultipleKeypresses( + prevState: KeyParseState, + input: Buffer | string | null = '' +): [ParsedInput[], KeyParseState] { + const isFlush = input === null + const inputString = isFlush ? '' : inputToString(input) + + // Get or create tokenizer + const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true }) + + // Tokenize the input + const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString) + + // Convert tokens to parsed keys, handling paste mode + const keys: ParsedInput[] = [] + let inPaste = prevState.mode === 'IN_PASTE' + let pasteBuffer = prevState.pasteBuffer + + for (const token of tokens) { + if (token.type === 'sequence') { + if (token.value === PASTE_START) { + inPaste = true + pasteBuffer = '' + } else if (token.value === PASTE_END) { + // Always emit a paste key, even for empty pastes. This allows + // downstream handlers to detect empty pastes (e.g., for clipboard + // image handling on macOS). The paste content may be empty string. + keys.push(createPasteKey(pasteBuffer)) + inPaste = false + pasteBuffer = '' + } else if (inPaste) { + // Sequences inside paste are treated as literal text + pasteBuffer += token.value + } else { + const response = parseTerminalResponse(token.value) + + if (response) { + keys.push({ kind: 'response', sequence: token.value, response }) + } else { + const mouse = parseMouseEvent(token.value) + + if (mouse) { + keys.push(mouse) + } else { + keys.push(parseKeypress(token.value)) + } + } + } + } else if (token.type === 'text') { + if (inPaste) { + pasteBuffer += token.value + } else if (/^\[<\d+;\d+;\d+[Mm]$/.test(token.value) || /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value)) { + // Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off + // otherwise). A heavy render blocked the event loop past App's 50ms + // flush timer, so the buffered ESC was flushed as a lone Escape and + // the continuation `[ = { + /* xterm/gnome ESC O letter */ + OP: 'f1', + OQ: 'f2', + OR: 'f3', + OS: 'f4', + /* Application keypad mode (numpad digits 0-9) */ + Op: '0', + Oq: '1', + Or: '2', + Os: '3', + Ot: '4', + Ou: '5', + Ov: '6', + Ow: '7', + Ox: '8', + Oy: '9', + /* Application keypad mode (numpad operators) */ + Oj: '*', + Ok: '+', + Ol: ',', + Om: '-', + On: '.', + Oo: '/', + OM: 'return', + /* xterm/rxvt ESC [ number ~ */ + '[11~': 'f1', + '[12~': 'f2', + '[13~': 'f3', + '[14~': 'f4', + /* from Cygwin and used in libuv */ + '[[A': 'f1', + '[[B': 'f2', + '[[C': 'f3', + '[[D': 'f4', + '[[E': 'f5', + /* common */ + '[15~': 'f5', + '[17~': 'f6', + '[18~': 'f7', + '[19~': 'f8', + '[20~': 'f9', + '[21~': 'f10', + '[23~': 'f11', + '[24~': 'f12', + /* xterm ESC [ letter */ + '[A': 'up', + '[B': 'down', + '[C': 'right', + '[D': 'left', + '[E': 'clear', + '[F': 'end', + '[H': 'home', + /* xterm/gnome ESC O letter */ + OA: 'up', + OB: 'down', + OC: 'right', + OD: 'left', + OE: 'clear', + OF: 'end', + OH: 'home', + /* xterm/rxvt ESC [ number ~ */ + '[1~': 'home', + '[2~': 'insert', + '[3~': 'delete', + '[4~': 'end', + '[5~': 'pageup', + '[6~': 'pagedown', + /* putty */ + '[[5~': 'pageup', + '[[6~': 'pagedown', + /* rxvt */ + '[7~': 'home', + '[8~': 'end', + /* rxvt keys with modifiers */ + '[a': 'up', + '[b': 'down', + '[c': 'right', + '[d': 'left', + '[e': 'clear', + + '[2$': 'insert', + '[3$': 'delete', + '[5$': 'pageup', + '[6$': 'pagedown', + '[7$': 'home', + '[8$': 'end', + + Oa: 'up', + Ob: 'down', + Oc: 'right', + Od: 'left', + Oe: 'clear', + + '[2^': 'insert', + '[3^': 'delete', + '[5^': 'pageup', + '[6^': 'pagedown', + '[7^': 'home', + '[8^': 'end', + /* misc. */ + '[Z': 'tab' +} + +export const nonAlphanumericKeys = [ + // Filter out single-character values (digits, operators from numpad) since + // those are printable characters that should produce input + ...Object.values(keyName).filter(v => v.length > 1), + // escape and backspace are assigned directly in parseKeypress (not via the + // keyName map), so the spread above misses them. Without these, ctrl+escape + // via Kitty/modifyOtherKeys leaks the literal word "escape" as input text + // (input-event.ts:58 assigns keypress.name when ctrl is set). + 'escape', + 'backspace', + 'wheelup', + 'wheeldown', + 'mouse' +] + +const isShiftKey = (code: string): boolean => { + return ['[a', '[b', '[c', '[d', '[e', '[2$', '[3$', '[5$', '[6$', '[7$', '[8$', '[Z'].includes(code) +} + +const isCtrlKey = (code: string): boolean => { + return ['Oa', 'Ob', 'Oc', 'Od', 'Oe', '[2^', '[3^', '[5^', '[6^', '[7^', '[8^'].includes(code) +} + +/** + * Decode XTerm-style modifier value to individual flags. + * Modifier encoding: 1 + (shift ? 1 : 0) + (alt ? 2 : 0) + (ctrl ? 4 : 0) + (super ? 8 : 0) + * + * Note: `meta` here means Alt/Option (bit 2). `super` is a distinct + * modifier (bit 8, i.e. Cmd on macOS / Win key). Most legacy terminal + * sequences can't express super — it only arrives via kitty keyboard + * protocol (CSI u) or xterm modifyOtherKeys. + */ +function decodeModifier(modifier: number): { + shift: boolean + meta: boolean + ctrl: boolean + super: boolean +} { + const m = modifier - 1 + + return { + shift: !!(m & 1), + meta: !!(m & 2), + ctrl: !!(m & 4), + super: !!(m & 8) + } +} + +/** + * Map keycode to key name for modifyOtherKeys/CSI u sequences. + * Handles both ASCII keycodes and Kitty keyboard protocol functional keys. + * + * Numpad codepoints are from Unicode Private Use Area, defined at: + * https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions + */ +function keycodeToName(keycode: number): string | undefined { + switch (keycode) { + case 9: + return 'tab' + + case 13: + return 'return' + + case 27: + return 'escape' + + case 32: + return 'space' + + case 127: + return 'backspace' + + // Kitty keyboard protocol numpad keys (KP_0 through KP_9) + case 57399: + return '0' + + case 57400: + return '1' + + case 57401: + return '2' + + case 57402: + return '3' + + case 57403: + return '4' + + case 57404: + return '5' + + case 57405: + return '6' + + case 57406: + return '7' + + case 57407: + return '8' + + case 57408: + return '9' + + case 57409: // KP_DECIMAL + return '.' + + case 57410: // KP_DIVIDE + return '/' + + case 57411: // KP_MULTIPLY + return '*' + + case 57412: // KP_SUBTRACT + return '-' + + case 57413: // KP_ADD + return '+' + + case 57414: // KP_ENTER + return 'return' + + case 57415: // KP_EQUAL + return '=' + + default: + // Printable ASCII characters + if (keycode >= 32 && keycode <= 126) { + return String.fromCharCode(keycode).toLowerCase() + } + + return undefined + } +} + +export type ParsedKey = { + kind: 'key' + fn: boolean + name: string | undefined + ctrl: boolean + meta: boolean + shift: boolean + option: boolean + super: boolean + sequence: string | undefined + raw: string | undefined + code?: string + isPasted: boolean +} + +/** A terminal response sequence (DECRPM, DA1, OSC reply, etc.) parsed + * out of the input stream. Not user input — consumers should dispatch + * to a response handler. */ +export type ParsedResponse = { + kind: 'response' + /** Raw escape sequence bytes, for debugging/logging */ + sequence: string + response: TerminalResponse +} + +/** SGR mouse event with coordinates. Emitted for clicks, drags, and + * releases (wheel events remain ParsedKey). col/row are 1-indexed + * from the terminal sequence (CSI < btn;col;row M/m). */ +export type ParsedMouse = { + kind: 'mouse' + /** Raw SGR button code. Low 2 bits = button (0=left,1=mid,2=right), + * bit 5 (0x20) = drag/motion, bit 6 (0x40) = wheel. */ + button: number + /** 'press' for M terminator, 'release' for m terminator */ + action: 'press' | 'release' + /** 1-indexed column (from terminal) */ + col: number + /** 1-indexed row (from terminal) */ + row: number + sequence: string +} + +/** Everything that can come out of the input parser: a user keypress/paste, + * a mouse click/drag event, or a terminal response to a query we sent. */ +export type ParsedInput = ParsedKey | ParsedMouse | ParsedResponse + +/** + * Parse an SGR mouse event sequence into a ParsedMouse, or null if not a + * mouse event or if it's a wheel event (wheel stays as ParsedKey for the + * keybinding system). Button bit 0x40 = wheel, bit 0x20 = drag/motion. + */ +function parseMouseEvent(s: string): ParsedMouse | null { + const match = SGR_MOUSE_RE.exec(s) + + if (!match) { + return null + } + + const button = parseInt(match[1]!, 10) + + // Wheel events (bit 6 set, low bits 0/1 for up/down) stay as ParsedKey + // so the keybinding system can route them to scroll handlers. + if ((button & 0x40) !== 0) { + return null + } + + return { + kind: 'mouse', + button, + action: match[4] === 'M' ? 'press' : 'release', + col: parseInt(match[2]!, 10), + row: parseInt(match[3]!, 10), + sequence: s + } +} + +function parseKeypress(s: string = ''): ParsedKey { + let parts + + const key: ParsedKey = { + kind: 'key', + name: '', + fn: false, + ctrl: false, + meta: false, + shift: false, + option: false, + super: false, + sequence: s, + raw: s, + isPasted: false + } + + key.sequence = key.sequence || s || key.name + + // Handle CSI u (kitty keyboard protocol): ESC [ codepoint [; modifier] u + // Example: ESC[13;2u = Shift+Enter, ESC[27u = Escape (no modifiers) + let match: RegExpExecArray | null + + if ((match = CSI_U_RE.exec(s))) { + const codepoint = parseInt(match[1]!, 10) + // Modifier defaults to 1 (no modifiers) when not present + const modifier = match[2] ? parseInt(match[2], 10) : 1 + const mods = decodeModifier(modifier) + const name = keycodeToName(codepoint) + + return { + kind: 'key', + name, + fn: false, + ctrl: mods.ctrl, + meta: mods.meta, + shift: mods.shift, + option: false, + super: mods.super, + sequence: s, + raw: s, + isPasted: false + } + } + + // Handle xterm modifyOtherKeys: ESC [ 27 ; modifier ; keycode ~ + // Must run before FN_KEY_RE — FN_KEY_RE only allows 2 params before ~ and + // would leave the tail as garbage if it partially matched. + if ((match = MODIFY_OTHER_KEYS_RE.exec(s))) { + const mods = decodeModifier(parseInt(match[1]!, 10)) + const name = keycodeToName(parseInt(match[2]!, 10)) + + return { + kind: 'key', + name, + fn: false, + ctrl: mods.ctrl, + meta: mods.meta, + shift: mods.shift, + option: false, + super: mods.super, + sequence: s, + raw: s, + isPasted: false + } + } + + // SGR mouse wheel events. Click/drag/release events are handled + // earlier by parseMouseEvent and emitted as ParsedMouse, so they + // never reach here. Mask with 0x43 (bits 6+1+0) to check wheel-flag + // + direction while ignoring modifier bits (Shift=0x04, Meta=0x08, + // Ctrl=0x10) — modified wheel events (e.g. Ctrl+scroll, button=80) + // should still be recognized as wheelup/wheeldown. + if ((match = SGR_MOUSE_RE.exec(s))) { + const button = parseInt(match[1]!, 10) + + if ((button & 0x43) === 0x40) { + return createNavKey(s, 'wheelup', false) + } + + if ((button & 0x43) === 0x41) { + return createNavKey(s, 'wheeldown', false) + } + + // Shouldn't reach here (parseMouseEvent catches non-wheel) but be safe + return createNavKey(s, 'mouse', false) + } + + // X10 mouse: CSI M + 3 raw bytes (Cb+32, Cx+32, Cy+32). Terminals that + // ignore DECSET 1006 (SGR) but honor 1000/1002 emit this legacy encoding. + // Button bits match SGR: 0x40 = wheel, low bit = direction. Non-wheel + // X10 events (clicks/drags) are swallowed here — we only enable mouse + // tracking in alt-screen and only need wheel for ScrollBox. + if (s.length === 6 && s.startsWith('\x1b[M')) { + const button = s.charCodeAt(3) - 32 + + if ((button & 0x43) === 0x40) { + return createNavKey(s, 'wheelup', false) + } + + if ((button & 0x43) === 0x41) { + return createNavKey(s, 'wheeldown', false) + } + + return createNavKey(s, 'mouse', false) + } + + if (s === '\r') { + key.raw = undefined + key.name = 'return' + } else if (s === '\n') { + key.name = 'enter' + } else if (s === '\t') { + key.name = 'tab' + } else if (s === '\b' || s === '\x1b\b') { + key.name = 'backspace' + key.meta = s.charAt(0) === '\x1b' + } else if (s === '\x7f' || s === '\x1b\x7f') { + key.name = 'backspace' + key.meta = s.charAt(0) === '\x1b' + } else if (s === '\x1b' || s === '\x1b\x1b') { + key.name = 'escape' + key.meta = s.length === 2 + } else if (s === ' ' || s === '\x1b ') { + key.name = 'space' + key.meta = s.length === 2 + } else if (s === '\x1f') { + key.name = '_' + key.ctrl = true + } else if (s <= '\x1a' && s.length === 1) { + key.name = String.fromCharCode(s.charCodeAt(0) + 'a'.charCodeAt(0) - 1) + key.ctrl = true + } else if (s.length === 1 && s >= '0' && s <= '9') { + key.name = 'number' + } else if (s.length === 1 && s >= 'a' && s <= 'z') { + key.name = s + } else if (s.length === 1 && s >= 'A' && s <= 'Z') { + key.name = s.toLowerCase() + key.shift = true + } else if ((parts = META_KEY_CODE_RE.exec(s))) { + key.meta = true + key.shift = /^[A-Z]$/.test(parts[1]!) + } else if ((parts = FN_KEY_RE.exec(s))) { + const segs = [...s] + + if (segs[0] === '\u001b' && segs[1] === '\u001b') { + key.option = true + } + + const code = [parts[1], parts[2], parts[4], parts[6]].filter(Boolean).join('') + + const modifier = ((parts[3] || parts[5] || 1) as number) - 1 + + key.ctrl = !!(modifier & 4) + key.meta = !!(modifier & 2) + key.super = !!(modifier & 8) + key.shift = !!(modifier & 1) + key.code = code + + key.name = keyName[code] + key.shift = isShiftKey(code) || key.shift + key.ctrl = isCtrlKey(code) || key.ctrl + } + + // iTerm in natural text editing mode + if (key.raw === '\x1Bb') { + key.meta = true + key.name = 'left' + } else if (key.raw === '\x1Bf') { + key.meta = true + key.name = 'right' + } + + switch (s) { + case '\u001b[1~': + return createNavKey(s, 'home', false) + + case '\u001b[4~': + return createNavKey(s, 'end', false) + + case '\u001b[5~': + return createNavKey(s, 'pageup', false) + + case '\u001b[6~': + return createNavKey(s, 'pagedown', false) + + case '\u001b[1;5D': + return createNavKey(s, 'left', true) + + case '\u001b[1;5C': + return createNavKey(s, 'right', true) + } + + return key +} + +function createNavKey(s: string, name: string, ctrl: boolean): ParsedKey { + return { + kind: 'key', + name, + ctrl, + meta: false, + shift: false, + option: false, + super: false, + fn: false, + sequence: s, + raw: s, + isPasted: false + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/reconciler.ts b/ui-tui/packages/hermes-ink/src/ink/reconciler.ts new file mode 100644 index 0000000000..7d50aedabe --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/reconciler.ts @@ -0,0 +1,532 @@ +import { appendFileSync } from 'fs' + +import createReconciler from 'react-reconciler' + +import { getYogaCounters } from '../native-ts/yoga-layout/index.js' +import { isEnvTruthy } from '../utils/envUtils.js' + +import { + appendChildNode, + clearYogaNodeReferences, + createNode, + createTextNode, + type DOMElement, + type DOMNodeAttribute, + type ElementNames, + insertBeforeNode, + markDirty, + removeChildNode, + setAttribute, + setStyle, + setTextNodeValue, + setTextStyles, + type TextNode +} from './dom.js' +import { Dispatcher } from './events/dispatcher.js' +import { EVENT_HANDLER_PROPS } from './events/event-handlers.js' +import { getFocusManager, getRootNode } from './focus.js' +import { LayoutDisplay } from './layout/node.js' +import applyStyles, { type Styles, type TextStyles } from './styles.js' + +// We need to conditionally perform devtools connection to avoid +// accidentally breaking other third-party code. +// See https://github.com/vadimdemedes/ink/issues/384 +if (process.env.NODE_ENV === 'development') { + try { + void import('./devtools.js') + } catch (error: any) { + if (error.code === 'ERR_MODULE_NOT_FOUND') { + // biome-ignore lint/suspicious/noConsole: intentional warning + console.warn( + ` +The environment variable DEV is set to true, so Ink tried to import \`react-devtools-core\`, +but this failed as it was not installed. Debugging with React Devtools requires it. + +To install use this command: + +$ npm install --save-dev react-devtools-core + `.trim() + '\n' + ) + } else { + throw error + } + } +} + +// -- + +type AnyObject = Record + +const diff = (before: AnyObject, after: AnyObject): AnyObject | undefined => { + if (before === after) { + return + } + + if (!before) { + return after + } + + const changed: AnyObject = {} + let isChanged = false + + for (const key of Object.keys(before)) { + const isDeleted = after ? !Object.hasOwn(after, key) : true + + if (isDeleted) { + changed[key] = undefined + isChanged = true + } + } + + if (after) { + for (const key of Object.keys(after)) { + if (after[key] !== before[key]) { + changed[key] = after[key] + isChanged = true + } + } + } + + return isChanged ? changed : undefined +} + +const cleanupYogaNode = (node: DOMElement | TextNode): void => { + const yogaNode = node.yogaNode + + if (yogaNode) { + yogaNode.unsetMeasureFunc() + // Clear all references BEFORE freeing to prevent other code from + // accessing freed WASM memory during concurrent operations + clearYogaNodeReferences(node) + yogaNode.freeRecursive() + } +} + +// -- + +type Props = Record + +type HostContext = { + isInsideText: boolean +} + +function setEventHandler(node: DOMElement, key: string, value: unknown): void { + if (!node._eventHandlers) { + node._eventHandlers = {} + } + + node._eventHandlers[key] = value +} + +function applyProp(node: DOMElement, key: string, value: unknown): void { + if (key === 'children') { + return + } + + if (key === 'style') { + setStyle(node, value as Styles) + + if (node.yogaNode) { + applyStyles(node.yogaNode, value as Styles) + } + + return + } + + if (key === 'textStyles') { + node.textStyles = value as TextStyles + + return + } + + if (EVENT_HANDLER_PROPS.has(key)) { + setEventHandler(node, key, value) + + return + } + + setAttribute(node, key, value as DOMNodeAttribute) +} + +// -- + +// react-reconciler's Fiber shape — only the fields we walk. The 5th arg to +// createInstance is the Fiber (`workInProgress` in react-reconciler.dev.js). +// _debugOwner is the component that rendered this element (dev builds only); +// return is the parent fiber (always present). We prefer _debugOwner since it +// skips past Box/Text wrappers to the actual named component. +type FiberLike = { + elementType?: { displayName?: string; name?: string } | string | null + _debugOwner?: FiberLike | null + return?: FiberLike | null +} + +export function getOwnerChain(fiber: unknown): string[] { + const chain: string[] = [] + const seen = new Set() + let cur = fiber as FiberLike | null | undefined + + for (let i = 0; cur && i < 50; i++) { + if (seen.has(cur)) { + break + } + + seen.add(cur) + const t = cur.elementType + + const name = + typeof t === 'function' + ? (t as { displayName?: string; name?: string }).displayName || + (t as { displayName?: string; name?: string }).name + : typeof t === 'string' + ? undefined // host element (ink-box etc) — skip + : t?.displayName || t?.name + + if (name && name !== chain[chain.length - 1]) { + chain.push(name) + } + + cur = cur._debugOwner ?? cur.return + } + + return chain +} + +let debugRepaints: boolean | undefined + +export function isDebugRepaintsEnabled(): boolean { + if (debugRepaints === undefined) { + debugRepaints = isEnvTruthy(process.env.CLAUDE_CODE_DEBUG_REPAINTS) + } + + return debugRepaints +} + +export const dispatcher = new Dispatcher() + +// --- COMMIT INSTRUMENTATION (temp debugging) --- + +const COMMIT_LOG = process.env.CLAUDE_CODE_COMMIT_LOG +let _commits = 0 +let _lastLog = 0 +let _lastCommitAt = 0 +let _maxGapMs = 0 +let _createCount = 0 +let _prepareAt = 0 +// --- END --- + +// --- SCROLL PROFILING (bench/scroll-e2e.sh reads via getLastYogaMs) --- +// Set by onComputeLayout wrapper in ink.tsx; read by onRender for phases. +let _lastYogaMs = 0 +let _lastCommitMs = 0 +let _commitStart = 0 + +export function recordYogaMs(ms: number): void { + _lastYogaMs = ms +} + +export function getLastYogaMs(): number { + return _lastYogaMs +} + +export function markCommitStart(): void { + _commitStart = performance.now() +} + +export function getLastCommitMs(): number { + return _lastCommitMs +} + +export function resetProfileCounters(): void { + _lastYogaMs = 0 + _lastCommitMs = 0 + _commitStart = 0 +} +// --- END --- + +const reconciler = createReconciler< + ElementNames, + Props, + DOMElement, + DOMElement, + TextNode, + DOMElement, + unknown, + unknown, + DOMElement, + HostContext, + null, // UpdatePayload - not used in React 19 + NodeJS.Timeout, + -1, + null +>({ + getRootHostContext: () => ({ isInsideText: false }), + prepareForCommit: () => { + if (COMMIT_LOG) { + _prepareAt = performance.now() + } + + return null + }, + preparePortalMount: () => null, + clearContainer: () => false, + resetAfterCommit(rootNode) { + _lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0 + _commitStart = 0 + + if (COMMIT_LOG) { + const now = performance.now() + _commits++ + const gap = _lastCommitAt > 0 ? now - _lastCommitAt : 0 + + if (gap > _maxGapMs) { + _maxGapMs = gap + } + + _lastCommitAt = now + const reconcileMs = _prepareAt > 0 ? now - _prepareAt : 0 + + if (gap > 30 || reconcileMs > 20 || _createCount > 50) { + appendFileSync( + COMMIT_LOG, + `${now.toFixed(1)} gap=${gap.toFixed(1)}ms reconcile=${reconcileMs.toFixed(1)}ms creates=${_createCount}\n` + ) + } + + _createCount = 0 + + if (now - _lastLog > 1000) { + appendFileSync(COMMIT_LOG, `${now.toFixed(1)} commits=${_commits}/s maxGap=${_maxGapMs.toFixed(1)}ms\n`) + _commits = 0 + _maxGapMs = 0 + _lastLog = now + } + } + + const _t0 = COMMIT_LOG ? performance.now() : 0 + + if (typeof rootNode.onComputeLayout === 'function') { + rootNode.onComputeLayout() + } + + if (COMMIT_LOG) { + const layoutMs = performance.now() - _t0 + + if (layoutMs > 20) { + const c = getYogaCounters() + + appendFileSync( + COMMIT_LOG, + `${_t0.toFixed(1)} SLOW_YOGA ${layoutMs.toFixed(1)}ms visited=${c.visited} measured=${c.measured} hits=${c.cacheHits} live=${c.live}\n` + ) + } + } + + if (process.env.NODE_ENV === 'test') { + if (rootNode.childNodes.length === 0 && rootNode.hasRenderedContent) { + return + } + + if (rootNode.childNodes.length > 0) { + rootNode.hasRenderedContent = true + } + + rootNode.onImmediateRender?.() + + return + } + + const _tr = COMMIT_LOG ? performance.now() : 0 + rootNode.onRender?.() + + if (COMMIT_LOG) { + const renderMs = performance.now() - _tr + + if (renderMs > 10) { + appendFileSync(COMMIT_LOG, `${_tr.toFixed(1)} SLOW_PAINT ${renderMs.toFixed(1)}ms\n`) + } + } + }, + getChildHostContext(parentHostContext: HostContext, type: ElementNames): HostContext { + const previousIsInsideText = parentHostContext.isInsideText + + const isInsideText = type === 'ink-text' || type === 'ink-virtual-text' || type === 'ink-link' + + if (previousIsInsideText === isInsideText) { + return parentHostContext + } + + return { isInsideText } + }, + shouldSetTextContent: () => false, + createInstance( + originalType: ElementNames, + newProps: Props, + _root: DOMElement, + hostContext: HostContext, + internalHandle?: unknown + ): DOMElement { + if (hostContext.isInsideText && originalType === 'ink-box') { + throw new Error(` can't be nested inside component`) + } + + const type = originalType === 'ink-text' && hostContext.isInsideText ? 'ink-virtual-text' : originalType + + const node = createNode(type) + + if (COMMIT_LOG) { + _createCount++ + } + + for (const [key, value] of Object.entries(newProps)) { + applyProp(node, key, value) + } + + if (isDebugRepaintsEnabled()) { + node.debugOwnerChain = getOwnerChain(internalHandle) + } + + return node + }, + createTextInstance(text: string, _root: DOMElement, hostContext: HostContext): TextNode { + if (!hostContext.isInsideText) { + throw new Error(`Text string "${text}" must be rendered inside component`) + } + + return createTextNode(text) + }, + resetTextContent() {}, + hideTextInstance(node) { + setTextNodeValue(node, '') + }, + unhideTextInstance(node, text) { + setTextNodeValue(node, text) + }, + getPublicInstance: (instance): DOMElement => instance as DOMElement, + hideInstance(node) { + node.isHidden = true + node.yogaNode?.setDisplay(LayoutDisplay.None) + markDirty(node) + }, + unhideInstance(node) { + node.isHidden = false + node.yogaNode?.setDisplay(LayoutDisplay.Flex) + markDirty(node) + }, + appendInitialChild: appendChildNode, + appendChild: appendChildNode, + insertBefore: insertBeforeNode, + finalizeInitialChildren(_node: DOMElement, _type: ElementNames, props: Props): boolean { + return props['autoFocus'] === true + }, + commitMount(node: DOMElement): void { + getFocusManager(node).handleAutoFocus(node) + }, + isPrimaryRenderer: true, + supportsMutation: true, + supportsPersistence: false, + supportsHydration: false, + scheduleTimeout: setTimeout, + cancelTimeout: clearTimeout, + noTimeout: -1, + getCurrentUpdatePriority: () => dispatcher.currentUpdatePriority, + beforeActiveInstanceBlur() {}, + afterActiveInstanceBlur() {}, + detachDeletedInstance() {}, + getInstanceFromNode: () => null, + prepareScopeUpdate() {}, + getInstanceFromScope: () => null, + appendChildToContainer: appendChildNode, + insertInContainerBefore: insertBeforeNode, + removeChildFromContainer(node: DOMElement, removeNode: DOMElement): void { + removeChildNode(node, removeNode) + cleanupYogaNode(removeNode) + getFocusManager(node).handleNodeRemoved(removeNode, node) + }, + // React 19 commitUpdate receives old and new props directly instead of an updatePayload + commitUpdate(node: DOMElement, _type: ElementNames, oldProps: Props, newProps: Props): void { + const props = diff(oldProps, newProps) + const style = diff(oldProps['style'] as Styles, newProps['style'] as Styles) + + if (props) { + for (const [key, value] of Object.entries(props)) { + if (key === 'style') { + setStyle(node, value as Styles) + + continue + } + + if (key === 'textStyles') { + setTextStyles(node, value as TextStyles) + + continue + } + + if (EVENT_HANDLER_PROPS.has(key)) { + setEventHandler(node, key, value) + + continue + } + + setAttribute(node, key, value as DOMNodeAttribute) + } + } + + if (style && node.yogaNode) { + applyStyles(node.yogaNode, style, newProps['style'] as Styles) + } + }, + commitTextUpdate(node: TextNode, _oldText: string, newText: string): void { + setTextNodeValue(node, newText) + }, + removeChild(node, removeNode) { + removeChildNode(node, removeNode) + cleanupYogaNode(removeNode) + + if (removeNode.nodeName !== '#text') { + const root = getRootNode(node) + root.focusManager!.handleNodeRemoved(removeNode, root) + } + }, + // React 19 required methods + maySuspendCommit(): boolean { + return false + }, + preloadInstance(): boolean { + return true + }, + startSuspendingCommit(): void {}, + suspendInstance(): void {}, + waitForCommitToBeReady(): null { + return null + }, + NotPendingTransition: null, + HostTransitionContext: { + $$typeof: Symbol.for('react.context'), + _currentValue: null + } as never, + setCurrentUpdatePriority(newPriority: number): void { + dispatcher.currentUpdatePriority = newPriority + }, + resolveUpdatePriority(): number { + return dispatcher.resolveEventPriority() + }, + resetFormInstance(): void {}, + requestPostPaintCallback(): void {}, + shouldAttemptEagerTransition(): boolean { + return false + }, + trackSchedulerEvent(): void {}, + resolveEventType(): string | null { + return dispatcher.currentEvent?.type ?? null + }, + resolveEventTimeStamp(): number { + return dispatcher.currentEvent?.timeStamp ?? -1.1 + } +}) + +// Wire the reconciler's discreteUpdates into the dispatcher. +// This breaks the import cycle: dispatcher.ts doesn't import reconciler.ts. +dispatcher.discreteUpdates = reconciler.discreteUpdates.bind(reconciler) + +export default reconciler diff --git a/ui-tui/packages/hermes-ink/src/ink/render-border.ts b/ui-tui/packages/hermes-ink/src/ink/render-border.ts new file mode 100644 index 0000000000..a4fff7cb50 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/render-border.ts @@ -0,0 +1,206 @@ +import chalk from 'chalk' +import cliBoxes, { type Boxes, type BoxStyle } from 'cli-boxes' + +import { applyColor } from './colorize.js' +import type { DOMNode } from './dom.js' +import type Output from './output.js' +import { stringWidth } from './stringWidth.js' +import type { Color } from './styles.js' + +export type BorderTextOptions = { + content: string // Pre-rendered string with ANSI color codes + position: 'top' | 'bottom' + align: 'start' | 'end' | 'center' + offset?: number // Only used with 'start' or 'end' alignment. Number of characters from the edge. +} + +export const CUSTOM_BORDER_STYLES = { + dashed: { + top: '╌', + left: '╎', + right: '╎', + bottom: '╌', + // there aren't any line-drawing characters for dashes unfortunately + topLeft: ' ', + topRight: ' ', + bottomLeft: ' ', + bottomRight: ' ' + } +} as const + +export type BorderStyle = keyof Boxes | keyof typeof CUSTOM_BORDER_STYLES | BoxStyle + +function embedTextInBorder( + borderLine: string, + text: string, + align: 'start' | 'end' | 'center', + offset: number = 0, + borderChar: string +): [before: string, text: string, after: string] { + const textLength = stringWidth(text) + const borderLength = borderLine.length + + if (textLength >= borderLength - 2) { + return ['', text.substring(0, borderLength), ''] + } + + let position: number + + if (align === 'center') { + position = Math.floor((borderLength - textLength) / 2) + } else if (align === 'start') { + position = offset + 1 // +1 to account for corner character + } else { + // align === 'end' + position = borderLength - textLength - offset - 1 // -1 for corner character + } + + // Ensure position is valid + position = Math.max(1, Math.min(position, borderLength - textLength - 1)) + + const before = borderLine.substring(0, 1) + borderChar.repeat(position - 1) + + const after = borderChar.repeat(borderLength - position - textLength - 1) + borderLine.substring(borderLength - 1) + + return [before, text, after] +} + +function styleBorderLine(line: string, color: Color | undefined, dim: boolean | undefined): string { + let styled = applyColor(line, color) + + if (dim) { + styled = chalk.dim(styled) + } + + return styled +} + +const renderBorder = (x: number, y: number, node: DOMNode, output: Output): void => { + if (node.style.borderStyle) { + const width = Math.floor(node.yogaNode!.getComputedWidth()) + const height = Math.floor(node.yogaNode!.getComputedHeight()) + + const box = + typeof node.style.borderStyle === 'string' + ? (CUSTOM_BORDER_STYLES[node.style.borderStyle as keyof typeof CUSTOM_BORDER_STYLES] ?? + cliBoxes[node.style.borderStyle as keyof Boxes]) + : node.style.borderStyle + + const topBorderColor = node.style.borderTopColor ?? node.style.borderColor + + const bottomBorderColor = node.style.borderBottomColor ?? node.style.borderColor + + const leftBorderColor = node.style.borderLeftColor ?? node.style.borderColor + + const rightBorderColor = node.style.borderRightColor ?? node.style.borderColor + + const dimTopBorderColor = node.style.borderTopDimColor ?? node.style.borderDimColor + + const dimBottomBorderColor = node.style.borderBottomDimColor ?? node.style.borderDimColor + + const dimLeftBorderColor = node.style.borderLeftDimColor ?? node.style.borderDimColor + + const dimRightBorderColor = node.style.borderRightDimColor ?? node.style.borderDimColor + + const showTopBorder = node.style.borderTop !== false + const showBottomBorder = node.style.borderBottom !== false + const showLeftBorder = node.style.borderLeft !== false + const showRightBorder = node.style.borderRight !== false + + const contentWidth = Math.max(0, width - (showLeftBorder ? 1 : 0) - (showRightBorder ? 1 : 0)) + + const topBorderLine = showTopBorder + ? (showLeftBorder ? box.topLeft : '') + box.top.repeat(contentWidth) + (showRightBorder ? box.topRight : '') + : '' + + // Handle text in top border + let topBorder: string | undefined + + if (showTopBorder && node.style.borderText?.position === 'top') { + const [before, text, after] = embedTextInBorder( + topBorderLine, + node.style.borderText.content, + node.style.borderText.align, + node.style.borderText.offset, + box.top + ) + + topBorder = + styleBorderLine(before, topBorderColor, dimTopBorderColor) + + text + + styleBorderLine(after, topBorderColor, dimTopBorderColor) + } else if (showTopBorder) { + topBorder = styleBorderLine(topBorderLine, topBorderColor, dimTopBorderColor) + } + + let verticalBorderHeight = height + + if (showTopBorder) { + verticalBorderHeight -= 1 + } + + if (showBottomBorder) { + verticalBorderHeight -= 1 + } + + verticalBorderHeight = Math.max(0, verticalBorderHeight) + + let leftBorder = (applyColor(box.left, leftBorderColor) + '\n').repeat(verticalBorderHeight) + + if (dimLeftBorderColor) { + leftBorder = chalk.dim(leftBorder) + } + + let rightBorder = (applyColor(box.right, rightBorderColor) + '\n').repeat(verticalBorderHeight) + + if (dimRightBorderColor) { + rightBorder = chalk.dim(rightBorder) + } + + const bottomBorderLine = showBottomBorder + ? (showLeftBorder ? box.bottomLeft : '') + + box.bottom.repeat(contentWidth) + + (showRightBorder ? box.bottomRight : '') + : '' + + // Handle text in bottom border + let bottomBorder: string | undefined + + if (showBottomBorder && node.style.borderText?.position === 'bottom') { + const [before, text, after] = embedTextInBorder( + bottomBorderLine, + node.style.borderText.content, + node.style.borderText.align, + node.style.borderText.offset, + box.bottom + ) + + bottomBorder = + styleBorderLine(before, bottomBorderColor, dimBottomBorderColor) + + text + + styleBorderLine(after, bottomBorderColor, dimBottomBorderColor) + } else if (showBottomBorder) { + bottomBorder = styleBorderLine(bottomBorderLine, bottomBorderColor, dimBottomBorderColor) + } + + const offsetY = showTopBorder ? 1 : 0 + + if (topBorder) { + output.write(x, y, topBorder) + } + + if (showLeftBorder) { + output.write(x, y + offsetY, leftBorder) + } + + if (showRightBorder) { + output.write(x + width - 1, y + offsetY, rightBorder) + } + + if (bottomBorder) { + output.write(x, y + height - 1, bottomBorder) + } + } +} + +export default renderBorder diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts new file mode 100644 index 0000000000..d9057725fe --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -0,0 +1,1529 @@ +import indentString from 'indent-string' + +import { applyTextStyles } from './colorize.js' +import type { DOMElement } from './dom.js' +import getMaxWidth from './get-max-width.js' +import type { Rectangle } from './layout/geometry.js' +import { LayoutDisplay, LayoutEdge, type LayoutNode } from './layout/node.js' +import { nodeCache, pendingClears } from './node-cache.js' +import type Output from './output.js' +import renderBorder from './render-border.js' +import type { Screen } from './screen.js' +import { squashTextNodesToSegments, type StyledSegment } from './squash-text-nodes.js' +import type { Color } from './styles.js' +import { isXtermJs } from './terminal.js' +import { widestLine } from './widest-line.js' +import wrapText from './wrap-text.js' + +// Matches detectXtermJsWheel() in ScrollKeybindingHandler.tsx — the curve +// and drain must agree on terminal detection. TERM_PROGRAM check is the sync +// fallback; isXtermJs() is the authoritative XTVERSION-probe result. +function isXtermJsHost(): boolean { + return process.env.TERM_PROGRAM === 'vscode' || isXtermJs() +} + +// Per-frame scratch: set when any node's yoga position/size differs from +// its cached value, or a child was removed. Read by ink.tsx to decide +// whether the full-damage sledgehammer (PR #20120) is needed this frame. +// Applies on both alt-screen and main-screen. Steady-state frames +// (spinner tick, clock tick, text append into a fixed-height box) don't +// shift layout → narrow damage bounds → O(changed cells) diff instead of +// O(rows×cols). +let layoutShifted = false + +export function resetLayoutShifted(): void { + layoutShifted = false +} + +export function didLayoutShift(): boolean { + return layoutShifted +} + +// DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes +// between frames (and nothing else moved), log-update.ts can emit a +// hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole +// viewport. top/bottom are 0-indexed inclusive screen rows; delta > 0 = +// content moved up (scrollTop increased, CSI n S). +export type ScrollHint = { top: number; bottom: number; delta: number } +let scrollHint: ScrollHint | null = null + +// Rects of position:absolute nodes from the PREVIOUS frame, used by +// ScrollBox's blit+shift third-pass repair (see usage site). Recorded at +// three paths — full-render nodeCache.set, node-level blit early-return, +// blitEscapingAbsoluteDescendants — so clean-overlay consecutive scrolls +// still have the rect. +let absoluteRectsPrev: Rectangle[] = [] +let absoluteRectsCur: Rectangle[] = [] + +export function resetScrollHint(): void { + scrollHint = null + absoluteRectsPrev = absoluteRectsCur + absoluteRectsCur = [] +} + +export function getScrollHint(): ScrollHint | null { + return scrollHint +} + +// The ScrollBox DOM node (if any) with pendingScrollDelta left after this +// frame's drain. renderer.ts calls markDirty(it) post-render so the NEXT +// frame's root blit check fails and we descend to continue draining. +// Without this, after the scrollbox's dirty flag is cleared (line ~721), +// the next frame blits root and never reaches the scrollbox — drain stalls. +let scrollDrainNode: DOMElement | null = null + +export function resetScrollDrainNode(): void { + scrollDrainNode = null +} + +export function getScrollDrainNode(): DOMElement | null { + return scrollDrainNode +} + +// At-bottom follow scroll event this frame. When streaming content +// triggers scrollTop = maxScroll, the ScrollBox records the delta + +// viewport bounds here. ink.tsx consumes it post-render to translate any active +// text selection by -delta so the highlight stays anchored to the TEXT +// (native terminal behavior — the selection walks up the screen as content +// scrolls, eventually clipping at the top). The frontFrame screen buffer +// still holds the old content at that point — captureScrolledRows reads +// from it before the front/back swap to preserve the text for copy. +export type FollowScroll = { + delta: number + viewportTop: number + viewportBottom: number +} +let followScroll: FollowScroll | null = null + +export function consumeFollowScroll(): FollowScroll | null { + const f = followScroll + followScroll = null + + return f +} + +// ── Native terminal drain (iTerm2/Ghostty/etc. — proportional events) ── +// Minimum rows applied per frame. Above this, drain is proportional (~3/4 +// of remaining) so big bursts catch up in log₄ frames while the tail +// decelerates smoothly. Hard cap is innerHeight-1 so DECSTBM hint fires. +const SCROLL_MIN_PER_FRAME = 4 + +// ── xterm.js (VS Code) smooth drain ── +// Low pending (≤5) drains ALL in one frame — slow wheel clicks should be +// instant (click → visible jump → done), not micro-stutter 1-row frames. +// Higher pending drains at a small fixed step so fast-scroll animation +// stays smooth (no big jumps). Pending >MAX snaps excess. +const SCROLL_INSTANT_THRESHOLD = 5 // ≤ this: drain all at once +const SCROLL_HIGH_PENDING = 12 // threshold for HIGH step +const SCROLL_STEP_MED = 2 // pending (INSTANT, HIGH): catch-up +const SCROLL_STEP_HIGH = 3 // pending ≥ HIGH: fast flick +const SCROLL_MAX_PENDING = 30 // snap excess beyond this + +// xterm.js adaptive drain. Returns rows applied; mutates pendingScrollDelta. +function drainAdaptive(node: DOMElement, pending: number, innerHeight: number): number { + const sign = pending > 0 ? 1 : -1 + let abs = Math.abs(pending) + let applied = 0 + + // Snap excess beyond animation window so big flicks don't coast. + if (abs > SCROLL_MAX_PENDING) { + applied += sign * (abs - SCROLL_MAX_PENDING) + abs = SCROLL_MAX_PENDING + } + + // ≤5: drain all (slow click = instant). Above: small fixed step. + const step = abs <= SCROLL_INSTANT_THRESHOLD ? abs : abs < SCROLL_HIGH_PENDING ? SCROLL_STEP_MED : SCROLL_STEP_HIGH + + applied += sign * step + const rem = abs - step + // Cap total at innerHeight-1 so DECSTBM blit+shift fast path fires + // (matches drainProportional). Excess stays in pendingScrollDelta. + const cap = Math.max(1, innerHeight - 1) + const totalAbs = Math.abs(applied) + + if (totalAbs > cap) { + const excess = totalAbs - cap + node.pendingScrollDelta = sign * (rem + excess) + + return sign * cap + } + + node.pendingScrollDelta = rem > 0 ? sign * rem : undefined + + return applied +} + +// Native proportional drain. step = max(MIN, floor(abs*3/4)), capped at +// innerHeight-1 so DECSTBM + blit+shift fast path fire. +function drainProportional(node: DOMElement, pending: number, innerHeight: number): number { + const abs = Math.abs(pending) + const cap = Math.max(1, innerHeight - 1) + const step = Math.min(cap, Math.max(SCROLL_MIN_PER_FRAME, (abs * 3) >> 2)) + + if (abs <= step) { + node.pendingScrollDelta = undefined + + return pending + } + + const applied = pending > 0 ? step : -step + node.pendingScrollDelta = pending - applied + + return applied +} + +// OSC 8 hyperlink escape sequences. Empty params (;;) — ansi-tokenize only +// recognizes this exact prefix. The id= param (for grouping wrapped lines) +// is added at terminal-output time in termio/osc.ts link(). +const OSC = '\u001B]' +const BEL = '\u0007' + +function wrapWithOsc8Link(text: string, url: string): string { + return `${OSC}8;;${url}${BEL}${text}${OSC}8;;${BEL}` +} + +/** + * Build a mapping from each character position in the plain text to its segment index. + * Returns an array where charToSegment[i] is the segment index for character i. + */ +function buildCharToSegmentMap(segments: StyledSegment[]): number[] { + const map: number[] = [] + + for (let i = 0; i < segments.length; i++) { + const len = segments[i]!.text.length + + for (let j = 0; j < len; j++) { + map.push(i) + } + } + + return map +} + +/** + * Apply styles to wrapped text by mapping each character back to its original segment. + * This preserves per-segment styles even when text wraps across lines. + * + * @param trimEnabled - Whether whitespace trimming is enabled (wrap-trim mode). + * When true, we skip whitespace in the original that was trimmed from the output. + * When false (wrap mode), all whitespace is preserved so no skipping is needed. + */ +function applyStylesToWrappedText( + wrappedPlain: string, + segments: StyledSegment[], + charToSegment: number[], + originalPlain: string, + trimEnabled: boolean = false +): string { + const lines = wrappedPlain.split('\n') + const resultLines: string[] = [] + + let charIndex = 0 + + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx]! + + // In trim mode, skip leading whitespace that was trimmed from this line. + // Only skip if the original has whitespace but the output line doesn't start + // with whitespace (meaning it was trimmed). If both have whitespace, the + // whitespace was preserved and we shouldn't skip. + if (trimEnabled && line.length > 0) { + const lineStartsWithWhitespace = /\s/.test(line[0]!) + + const originalHasWhitespace = charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!) + + // Only skip if original has whitespace but line doesn't + if (originalHasWhitespace && !lineStartsWithWhitespace) { + while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) { + charIndex++ + } + } + } + + let styledLine = '' + let runStart = 0 + let runSegmentIndex = charToSegment[charIndex] ?? 0 + + for (let i = 0; i < line.length; i++) { + const currentSegmentIndex = charToSegment[charIndex] ?? runSegmentIndex + + if (currentSegmentIndex !== runSegmentIndex) { + // Flush the current run + const runText = line.slice(runStart, i) + const segment = segments[runSegmentIndex] + + if (segment) { + let styled = applyTextStyles(runText, segment.styles) + + if (segment.hyperlink) { + styled = wrapWithOsc8Link(styled, segment.hyperlink) + } + + styledLine += styled + } else { + styledLine += runText + } + + runStart = i + runSegmentIndex = currentSegmentIndex + } + + charIndex++ + } + + // Flush the final run + const runText = line.slice(runStart) + const segment = segments[runSegmentIndex] + + if (segment) { + let styled = applyTextStyles(runText, segment.styles) + + if (segment.hyperlink) { + styled = wrapWithOsc8Link(styled, segment.hyperlink) + } + + styledLine += styled + } else { + styledLine += runText + } + + resultLines.push(styledLine) + + // Skip newline character in original that corresponds to this line break. + // This is needed when the original text contains actual newlines (not just + // wrapping-inserted newlines). Without this, charIndex gets out of sync + // because the newline is in originalPlain/charToSegment but not in the + // split lines. + if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') { + charIndex++ + } + + // In trim mode, skip whitespace that was replaced by newline when wrapping. + // We skip whitespace in the original until we reach a character that matches + // the first character of the next line. This handles cases like: + // - "AB \tD" wrapped to "AB\n\tD" - skip spaces until we hit the tab + // In non-trim mode, whitespace is preserved so no skipping is needed. + if (trimEnabled && lineIdx < lines.length - 1) { + const nextLine = lines[lineIdx + 1]! + const nextLineFirstChar = nextLine.length > 0 ? nextLine[0] : null + + // Skip whitespace until we hit a char that matches the next line's first char + while (charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)) { + // Stop if we found the character that starts the next line + if (nextLineFirstChar !== null && originalPlain[charIndex] === nextLineFirstChar) { + break + } + + charIndex++ + } + } + } + + return resultLines.join('\n') +} + +/** + * Wrap text and record which output lines are soft-wrap continuations + * (i.e. the `\n` before them was inserted by word-wrap, not in the + * source). wrapAnsi already processes each input line independently, so + * wrapping per-input-line here gives identical output to a single + * whole-string wrap while letting us mark per-piece provenance. + * Truncate modes never add newlines (cli-truncate is whole-string) so + * they fall through with softWrap undefined — no tracking, no behavior + * change from the pre-softWrap path. + */ +function wrapWithSoftWrap( + plainText: string, + maxWidth: number, + textWrap: Parameters[2] +): { wrapped: string; softWrap: boolean[] | undefined } { + if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') { + return { + wrapped: wrapText(plainText, maxWidth, textWrap), + softWrap: undefined + } + } + + const origLines = plainText.split('\n') + const outLines: string[] = [] + const softWrap: boolean[] = [] + + for (const orig of origLines) { + const pieces = wrapText(orig, maxWidth, textWrap).split('\n') + + for (let i = 0; i < pieces.length; i++) { + outLines.push(pieces[i]!) + softWrap.push(i > 0) + } + } + + return { wrapped: outLines.join('\n'), softWrap } +} + +// If parent container is ``, text nodes will be treated as separate nodes in +// the tree and will have their own coordinates in the layout. +// To ensure text nodes are aligned correctly, take X and Y of the first text node +// and use it as offset for the rest of the nodes +// Only first node is taken into account, because other text nodes can't have margin or padding, +// so their coordinates will be relative to the first node anyway +function applyPaddingToText(node: DOMElement, text: string, softWrap?: boolean[]): string { + const yogaNode = node.childNodes[0]?.yogaNode + + if (yogaNode) { + const offsetX = yogaNode.getComputedLeft() + const offsetY = yogaNode.getComputedTop() + text = '\n'.repeat(offsetY) + indentString(text, offsetX) + + if (softWrap && offsetY > 0) { + // Prepend `false` for each padding line so indices stay aligned + // with text.split('\n'). Mutate in place — caller owns the array. + softWrap.unshift(...Array(offsetY).fill(false)) + } + } + + return text +} + +// After nodes are laid out, render each to output object, which later gets rendered to terminal +function renderNodeToOutput( + node: DOMElement, + output: Output, + { + offsetX = 0, + offsetY = 0, + prevScreen, + skipSelfBlit = false, + inheritedBackgroundColor + }: { + offsetX?: number + offsetY?: number + prevScreen: Screen | undefined + // Force this node to descend instead of blitting its own rect, while + // still passing prevScreen to children. Used for non-opaque absolute + // overlays over a dirty clipped region: the overlay's full rect has + // transparent gaps (stale underlying content in prevScreen), but its + // opaque descendants' narrower rects are safe to blit. + skipSelfBlit?: boolean + inheritedBackgroundColor?: Color + } +): void { + const { yogaNode } = node + + if (yogaNode) { + if (yogaNode.getDisplay() === LayoutDisplay.None) { + // Clear old position if node was visible before becoming hidden + if (node.dirty) { + const cached = nodeCache.get(node) + + if (cached) { + output.clear({ + x: Math.floor(cached.x), + y: Math.floor(cached.y), + width: Math.floor(cached.width), + height: Math.floor(cached.height) + }) + // Drop descendants' cache too — hideInstance's markDirty walks UP + // only, so descendants' .dirty stays false. Their nodeCache entries + // survive with pre-hide rects. On unhide, if position didn't shift, + // the blit check at line ~432 passes and copies EMPTY cells from + // prevScreen (cleared here) → content vanishes. + dropSubtreeCache(node) + layoutShifted = true + } + } + + return + } + + // Left and top positions in Yoga are relative to their parent node + const x = offsetX + yogaNode.getComputedLeft() + const yogaTop = yogaNode.getComputedTop() + let y = offsetY + yogaTop + const width = yogaNode.getComputedWidth() + const height = yogaNode.getComputedHeight() + + // Absolute-positioned overlays (e.g. autocomplete menus with bottom='100%') + // can compute negative screen y when they extend above the viewport. Without + // clamping, setCellAt drops cells at y<0, clipping the TOP of the content + // (best matches in an autocomplete). By clamping to 0, we shift the element + // down so the top rows are visible and the bottom overflows below — the + // opaque prop ensures it paints over whatever is underneath. + if (y < 0 && node.style.position === 'absolute') { + y = 0 + } + + // Check if we can skip this subtree (clean node with unchanged layout). + // Blit cells from previous screen instead of re-rendering. + const cached = nodeCache.get(node) + + if ( + !node.dirty && + !skipSelfBlit && + node.pendingScrollDelta === undefined && + cached && + cached.x === x && + cached.y === y && + cached.width === width && + cached.height === height && + prevScreen + ) { + const fx = Math.floor(x) + const fy = Math.floor(y) + const fw = Math.floor(width) + const fh = Math.floor(height) + output.blit(prevScreen, fx, fy, fw, fh) + + if (node.style.position === 'absolute') { + absoluteRectsCur.push(cached) + } + + // Absolute descendants can paint outside this node's layout bounds + // (e.g. a slash menu with position='absolute' bottom='100%' floats + // above). If a dirty clipped sibling re-rendered and overwrote those + // cells, the blit above only restored this node's own rect — the + // absolute descendants' cells are lost. Re-blit them from prevScreen + // so the overlays survive. + blitEscapingAbsoluteDescendants(node, output, prevScreen, fx, fy, fw, fh) + + return + } + + // Clear stale content from the old position when re-rendering. + // Dirty: content changed. Moved: position/size changed (e.g., sibling + // above changed height), old cells still on the terminal. + const positionChanged = + cached !== undefined && (cached.x !== x || cached.y !== y || cached.width !== width || cached.height !== height) + + if (positionChanged) { + layoutShifted = true + } + + if (cached && (node.dirty || positionChanged)) { + output.clear( + { + x: Math.floor(cached.x), + y: Math.floor(cached.y), + width: Math.floor(cached.width), + height: Math.floor(cached.height) + }, + node.style.position === 'absolute' + ) + } + + // Read before deleting — hasRemovedChild disables prevScreen blitting + // for siblings to prevent stale overflow content from being restored. + const clears = pendingClears.get(node) + const hasRemovedChild = clears !== undefined + + if (hasRemovedChild) { + layoutShifted = true + + for (const rect of clears) { + output.clear({ + x: Math.floor(rect.x), + y: Math.floor(rect.y), + width: Math.floor(rect.width), + height: Math.floor(rect.height) + }) + } + + pendingClears.delete(node) + } + + // Yoga squeezed this node to zero height (overflow in a height-constrained + // parent) AND a sibling lands at the same y. Skip rendering — both would + // write to the same row; if the sibling's content is shorter, this node's + // tail chars ghost (e.g. "false" + "true" = "truee"). The clear above + // already handled the visible→squeezed transition. + // + // The sibling-overlap check is load-bearing: Yoga's pixel-grid rounding + // can give a box h=0 while still leaving a row for it (next sibling at + // y+1, not y). HelpV2's third shortcuts column hits this — skipping + // unconditionally drops "ctrl + z to suspend" from /help output. + if (height === 0 && siblingSharesY(node, yogaNode)) { + nodeCache.set(node, { x, y, width, height, top: yogaTop }) + node.dirty = false + + return + } + + if (node.nodeName === 'ink-raw-ansi') { + // Pre-rendered ANSI content. The producer already wrapped to width and + // emitted terminal-ready escape codes. Skip squash, measure, wrap, and + // style re-application — output.write() parses ANSI directly into cells. + const text = node.attributes['rawText'] as string + + if (text) { + output.write(x, y, text) + } + } else if (node.nodeName === 'ink-text') { + const segments = squashTextNodesToSegments( + node, + inheritedBackgroundColor ? { backgroundColor: inheritedBackgroundColor } : undefined + ) + + // First, get plain text to check if wrapping is needed + const plainText = segments.map(s => s.text).join('') + + if (plainText.length > 0) { + // Upstream Ink uses getMaxWidth(yogaNode) unclamped here. That + // width comes from Yoga's AtMost pass and can exceed the actual + // screen space (see getMaxWidth docstring). Yoga's height for this + // node already reflects the constrained Exactly pass, so clamping + // the wrap width here keeps line count consistent with layout. + // Without this, characters past the screen edge are dropped by + // setCellAt's bounds check. + const maxWidth = Math.min(getMaxWidth(yogaNode), output.width - x) + const textWrap = node.style.textWrap ?? 'wrap' + + // Check if wrapping is needed + const needsWrapping = widestLine(plainText) > maxWidth + + let text: string + let softWrap: boolean[] | undefined + + if (needsWrapping && segments.length === 1) { + // Single segment: wrap plain text first, then apply styles to each line + const segment = segments[0]! + const w = wrapWithSoftWrap(plainText, maxWidth, textWrap) + softWrap = w.softWrap + text = w.wrapped + .split('\n') + .map(line => { + let styled = applyTextStyles(line, segment.styles) + + // Apply OSC 8 hyperlink per-line so each line is independently + // clickable. output.ts splits on newlines and tokenizes each + // line separately, so a single wrapper around the whole block + // would only apply the hyperlink to the first line. + if (segment.hyperlink) { + styled = wrapWithOsc8Link(styled, segment.hyperlink) + } + + return styled + }) + .join('\n') + } else if (needsWrapping) { + // Multiple segments with wrapping: wrap plain text first, then re-apply + // each segment's styles based on character positions. This preserves + // per-segment styles even when text wraps across lines. + const w = wrapWithSoftWrap(plainText, maxWidth, textWrap) + softWrap = w.softWrap + const charToSegment = buildCharToSegmentMap(segments) + text = applyStylesToWrappedText(w.wrapped, segments, charToSegment, plainText, textWrap === 'wrap-trim') + // Hyperlinks are handled per-run in applyStylesToWrappedText via + // wrapWithOsc8Link, similar to how styles are applied per-run. + } else { + // No wrapping needed: apply styles directly + text = segments + .map(segment => { + let styledText = applyTextStyles(segment.text, segment.styles) + + if (segment.hyperlink) { + styledText = wrapWithOsc8Link(styledText, segment.hyperlink) + } + + return styledText + }) + .join('') + } + + text = applyPaddingToText(node, text, softWrap) + + output.write(x, y, text, softWrap) + } + } else if (node.nodeName === 'ink-box') { + const boxBackgroundColor = node.style.backgroundColor ?? inheritedBackgroundColor + + // Mark this box's region as non-selectable (fullscreen text + // selection). noSelect ops are applied AFTER blits/writes in + // output.get(), so this wins regardless of what's rendered into + // the region — including blits from prevScreen when the box is + // clean (the op is emitted on both the dirty-render path here + // AND on the blit fast-path at line ~235 since blitRegion copies + // the noSelect bitmap alongside cells). + // + // 'from-left-edge' extends the exclusion from col 0 so any + // upstream indentation (tool prefix, tree lines) is covered too + // — a multi-row drag over a diff gutter shouldn't pick up the + // ` ⎿ ` prefix on row 0 or the blank cells under it on row 1+. + if (node.style.noSelect) { + const boxX = Math.floor(x) + const fromEdge = node.style.noSelect === 'from-left-edge' + output.noSelect({ + x: fromEdge ? 0 : boxX, + y: Math.floor(y), + width: fromEdge ? boxX + Math.floor(width) : Math.floor(width), + height: Math.floor(height) + }) + } + + const overflowX = node.style.overflowX ?? node.style.overflow + const overflowY = node.style.overflowY ?? node.style.overflow + const clipHorizontally = overflowX === 'hidden' || overflowX === 'scroll' + const clipVertically = overflowY === 'hidden' || overflowY === 'scroll' + const isScrollY = overflowY === 'scroll' + + const needsClip = clipHorizontally || clipVertically + let y1: number | undefined + let y2: number | undefined + + if (needsClip) { + const x1 = clipHorizontally ? x + yogaNode.getComputedBorder(LayoutEdge.Left) : undefined + + const x2 = clipHorizontally + ? x + yogaNode.getComputedWidth() - yogaNode.getComputedBorder(LayoutEdge.Right) + : undefined + + y1 = clipVertically ? y + yogaNode.getComputedBorder(LayoutEdge.Top) : undefined + + y2 = clipVertically + ? y + yogaNode.getComputedHeight() - yogaNode.getComputedBorder(LayoutEdge.Bottom) + : undefined + + output.clip({ x1, x2, y1, y2 }) + } + + if (isScrollY) { + // Scroll containers follow the ScrollBox component structure: + // a single content-wrapper child with flexShrink:0 (doesn't shrink + // to fit), whose children are the scrollable items. scrollHeight + // comes from the wrapper's intrinsic Yoga height. The wrapper is + // rendered with its Y translated by -scrollTop; its children are + // culled against the visible window. + const padTop = yogaNode.getComputedPadding(LayoutEdge.Top) + + const innerHeight = Math.max( + 0, + (y2 ?? y + height) - (y1 ?? y) - padTop - yogaNode.getComputedPadding(LayoutEdge.Bottom) + ) + + const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as DOMElement | undefined + + const contentYoga = content?.yogaNode + // scrollHeight is the intrinsic height of the content wrapper. + // Do NOT add getComputedTop() — that's the wrapper's offset + // within the viewport (equal to the scroll container's + // paddingTop), and innerHeight already subtracts padding, so + // including it double-counts padding and inflates maxScroll. + const scrollHeight = contentYoga?.getComputedHeight() ?? 0 + // Capture previous scroll bounds BEFORE overwriting — the at-bottom + // follow check compares against last frame's max. + const prevScrollHeight = node.scrollHeight ?? scrollHeight + const prevInnerHeight = node.scrollViewportHeight ?? innerHeight + node.scrollHeight = scrollHeight + node.scrollViewportHeight = innerHeight + // Absolute screen-buffer row where the scrollable area (inside + // padding) begins. Exposed via ScrollBoxHandle.getViewportTop() so + // drag-to-scroll can detect when the drag leaves the scroll viewport. + node.scrollViewportTop = (y1 ?? y) + padTop + + const maxScroll = Math.max(0, scrollHeight - innerHeight) + + // scrollAnchor: scroll so the anchored element's top is at the + // viewport top (plus offset). Yoga is FRESH — same calculateLayout + // pass that just produced scrollHeight. Deterministic alternative + // to scrollTo(N) which bakes a number that's stale by the throttled + // render; the element ref defers the read to now. One-shot snap. + // A prior eased-seek version (proportional drain over ~5 frames) + // moved scrollTop without firing React's notify → parent's quantized + // store snapshot never updated → StickyTracker got stale range props + // → firstVisible wrong. Also: SCROLL_MIN_PER_FRAME=4 with snap-at-1 + // ping-ponged forever at delta=2. Smooth needs drain-end notify + // plumbing; shipping instant first. stickyScroll overrides. + if (node.scrollAnchor) { + const anchorTop = node.scrollAnchor.el.yogaNode?.getComputedTop() + + if (anchorTop != null) { + node.scrollTop = anchorTop + node.scrollAnchor.offset + node.pendingScrollDelta = undefined + } + + node.scrollAnchor = undefined + } + + // At-bottom follow. Positional: if scrollTop was at (or past) the + // previous max, pin to the new max. Scroll away → stop following; + // scroll back (or scrollToBottom/sticky attr) → resume. The sticky + // flag is OR'd in for cold start (scrollTop=0 before first layout) + // and scrollToBottom-from-far-away (flag set before scrollTop moves) + // — the imperative field takes precedence over the attribute so + // scrollTo/scrollBy can break stickiness. pendingDelta<0 guard: + // don't cancel an in-flight scroll-up when content races in. + // Capture scrollTop before follow so ink.tsx can translate any + // active text selection by the same delta (native terminal behavior: + // view keeps scrolling, highlight walks up with the text). + const scrollTopBeforeFollow = node.scrollTop ?? 0 + + const sticky = node.stickyScroll ?? Boolean(node.attributes['stickyScroll']) + + const prevMaxScroll = Math.max(0, prevScrollHeight - prevInnerHeight) + // Positional check only valid when content grew — virtualization can + // transiently SHRINK scrollHeight (tail unmount + stale heightCache + // spacer) making scrollTop >= prevMaxScroll true by artifact, not + // because the user was at bottom. + const grew = scrollHeight >= prevScrollHeight + + const atBottom = sticky || (grew && scrollTopBeforeFollow >= prevMaxScroll) + + if (atBottom && (node.pendingScrollDelta ?? 0) >= 0) { + node.scrollTop = maxScroll + node.pendingScrollDelta = undefined + + // Sync flag so useVirtualScroll's isSticky() agrees with positional + // state — sticky-broken-but-at-bottom (wheel tremor, click-select + // at max) otherwise leaves useVirtualScroll's clamp holding the + // viewport short of new streaming content. scrollTo/scrollBy set + // false; this restores true, same as scrollToBottom() would. + // Only restore when (a) positionally at bottom and (b) the flag + // was explicitly broken (===false) by scrollTo/scrollBy. When + // undefined (never set by user action) leave it alone — setting it + // would make the sticky flag sticky-by-default and lock out + // direct scrollTop writes (e.g. the alt-screen-perf test). + if (node.stickyScroll === false && scrollTopBeforeFollow >= prevMaxScroll) { + node.stickyScroll = true + } + } + + const followDelta = (node.scrollTop ?? 0) - scrollTopBeforeFollow + + if (followDelta > 0) { + const vpTop = node.scrollViewportTop ?? 0 + followScroll = { + delta: followDelta, + viewportTop: vpTop, + viewportBottom: vpTop + innerHeight - 1 + } + } + + // Drain pendingScrollDelta. Native terminals (proportional burst + // events) use proportional drain; xterm.js (VS Code, sparse events + + // app-side accel curve) uses adaptive small-step drain. isXtermJs() + // depends on the async XTVERSION probe, but by the time this runs + // (pendingScrollDelta is only set by wheel events, >>50ms after + // startup) the probe has resolved — same timing guarantee the + // wheel-accel curve relies on. + let cur = node.scrollTop ?? 0 + const pending = node.pendingScrollDelta + const cMin = node.scrollClampMin + const cMax = node.scrollClampMax + const haveClamp = cMin !== undefined && cMax !== undefined + + if (pending !== undefined && pending !== 0) { + // Drain continues even past the clamp — the render-clamp below + // holds the VISUAL at the mounted edge regardless. Hard-stopping + // here caused stop-start jutter: drain hits edge → pause → React + // commits → clamp widens → drain resumes → edge again. Letting + // scrollTop advance smoothly while the clamp lags gives continuous + // visual scroll at React's commit rate (the clamp catches up each + // commit). But THROTTLE the drain when already past the clamp so + // scrollTop doesn't race 5000 rows ahead of the mounted range + // (slide-cap would then take 200 commits to catch up = long + // perceived stall at the edge). Past-clamp drain caps at ~4 rows/ + // frame, roughly matching React's slide rate so the gap stays + // bounded and catch-up is quick once input stops. + const pastClamp = haveClamp && ((pending < 0 && cur < cMin) || (pending > 0 && cur > cMax)) + + const eff = pastClamp ? Math.min(4, innerHeight >> 3) : innerHeight + cur += isXtermJsHost() ? drainAdaptive(node, pending, eff) : drainProportional(node, pending, eff) + } else if (pending === 0) { + // Opposite scrollBy calls cancelled to zero — clear so we don't + // schedule an infinite loop of no-op drain frames. + node.pendingScrollDelta = undefined + } + + let scrollTop = Math.max(0, Math.min(cur, maxScroll)) + + // Virtual-scroll clamp: if scrollTop raced past the currently-mounted + // range (burst PageUp before React re-renders), render at the EDGE of + // the mounted children instead of blank spacer. Do NOT write back to + // node.scrollTop — the clamped value is for this paint only; the real + // scrollTop stays so React's next commit sees the target and mounts + // the right range. Not scheduling scrollDrainNode here keeps the + // clamp passive — React's commit → resetAfterCommit → onRender will + // paint again with fresh bounds. + const clamped = haveClamp ? Math.max(cMin, Math.min(scrollTop, cMax)) : scrollTop + + node.scrollTop = scrollTop + + // Clamp hitting top/bottom consumes any remainder. Set drainPending + // only after clamp so a wasted no-op frame isn't scheduled. + if (scrollTop !== cur) { + node.pendingScrollDelta = undefined + } + + if (node.pendingScrollDelta !== undefined) { + scrollDrainNode = node + } + + scrollTop = clamped + + if (content && contentYoga) { + // Compute content wrapper's absolute render position with scroll + // offset applied, then render its children with culling. + const contentX = x + contentYoga.getComputedLeft() + const contentY = y + contentYoga.getComputedTop() - scrollTop + // layoutShifted detection gap: when scrollTop moves by >= viewport + // height (batched PageUps, fast wheel), every visible child gets + // culled (cache dropped) and every newly-visible child has no + // cache — so the children's positionChanged check can't fire. + // The content wrapper's cached y (which encodes -scrollTop) is + // the only node that survives to witness the scroll. + const contentCached = nodeCache.get(content) + let hint: ScrollHint | null = null + + if (contentCached && contentCached.y !== contentY) { + // delta = newScrollTop - oldScrollTop (positive = scrolled down). + // Capture a DECSTBM hint if the container itself didn't move + // and the shift fits within the viewport — otherwise the full + // rewrite is needed anyway, and layoutShifted stays the fallback. + const delta = contentCached.y - contentY + const regionTop = Math.floor(y + contentYoga.getComputedTop()) + const regionBottom = regionTop + innerHeight - 1 + + if (cached?.y === y && cached.height === height && innerHeight > 0 && Math.abs(delta) < innerHeight) { + hint = { top: regionTop, bottom: regionBottom, delta } + scrollHint = hint + } else { + layoutShifted = true + } + } + + // Fast path: scroll (hint captured) with usable prevScreen. + // Blit prevScreen's scroll region into next.screen, shift in-place + // by delta (mirrors DECSTBM), then render ONLY the edge rows. The + // nested clip keeps child writes out of stable rows — a tall child + // that spans edge+stable still renders but stable cells are + // clipped, preserving the blit. Avoids re-rendering every visible + // child (expensive for long syntax-highlighted transcripts). + // + // When content.dirty (e.g. streaming text at the bottom of the + // scroll), we still use the fast path — the dirty child is almost + // always in the edge rows (the bottom, where new content appears). + // After edge rendering, any dirty children in stable rows are + // re-rendered in a second pass to avoid showing stale blitted + // content. + // + // Guard: the fast path only handles pure scroll or bottom-append. + // Child removal/insertion changes the content height in a way that + // doesn't match the scroll delta — fall back to the full path so + // removed children don't leave stale cells and shifted siblings + // render at their new positions. + const scrollHeight = contentYoga.getComputedHeight() + const prevHeight = contentCached?.height ?? scrollHeight + const heightDelta = scrollHeight - prevHeight + + const safeForFastPath = !hint || heightDelta === 0 || (hint.delta > 0 && heightDelta === hint.delta) + + // scrollHint is set above when hint is captured. If safeForFastPath + // is false the full path renders a next.screen that doesn't match + // the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as + // content bleeding through during scroll-up + streaming). Clear it. + if (!safeForFastPath) { + scrollHint = null + } + + if (hint && prevScreen && safeForFastPath) { + const { top, bottom, delta } = hint + const w = Math.floor(width) + output.blit(prevScreen, Math.floor(x), top, w, bottom - top + 1) + output.shift(top, bottom, delta) + // Edge rows: new content entering the viewport. + const edgeTop = delta > 0 ? bottom - delta + 1 : top + const edgeBottom = delta > 0 ? bottom : top - delta - 1 + output.clear({ + x: Math.floor(x), + y: edgeTop, + width: w, + height: edgeBottom - edgeTop + 1 + }) + output.clip({ + x1: undefined, + x2: undefined, + y1: edgeTop, + y2: edgeBottom + 1 + }) + + // Snapshot dirty children before the first pass — the first + // pass clears dirty flags, and edge-spanning children would be + // missed by the second pass without this snapshot. + const dirtyChildren = content.dirty + ? new Set(content.childNodes.filter(c => (c as DOMElement).dirty)) + : null + + renderScrolledChildren( + content, + output, + contentX, + contentY, + hasRemovedChild, + undefined, + // Cull to edge in child-local coords (inverse of contentY offset). + edgeTop - contentY, + edgeBottom + 1 - contentY, + boxBackgroundColor, + true + ) + output.unclip() + + // Second pass: re-render children in stable rows whose screen + // position doesn't match where the shift put their old pixels. + // Covers TWO cases: + // 1. Dirty children — their content changed, blitted pixels are + // stale regardless of position. + // 2. Clean children BELOW a middle-growth point — when a dirty + // sibling above them grows, their yogaTop increases but + // scrollTop increases by the same amount (sticky), so their + // screenY is CONSTANT. The shift moved their old pixels to + // screenY-delta (wrong); they should stay at screenY. Without + // this, the spinner/tmux-monitor ghost at shifted positions + // during streaming (e.g. triple spinner, pill duplication). + // For bottom-append (the common case), all clean children are + // ABOVE the growth point; their screenY decreased by delta and + // the shift put them at the right place — skipped here, fast + // path preserved. + if (dirtyChildren) { + const edgeTopLocal = edgeTop - contentY + const edgeBottomLocal = edgeBottom + 1 - contentY + const spaces = ' '.repeat(w) + // Track cumulative height change of children iterated so far. + // A clean child's yogaTop is unchanged iff this is zero (no + // sibling above it grew/shrank/mounted). When zero, the skip + // check cached.y−delta === screenY reduces to delta === delta + // (tautology) → skip without yoga reads. Restores O(dirty) + // that #24536 traded away: for bottom-append the dirty child + // is last (all clean children skip); for virtual-scroll range + // shift the topSpacer shrink + new-item heights self-balance + // to zero before reaching the clean block. Middle-growth + // leaves shift non-zero → clean children after the growth + // point fall through to yoga + the fine-grained check below, + // preserving the ghost-box fix. + let cumHeightShift = 0 + + for (const childNode of content.childNodes) { + const childElem = childNode as DOMElement + const isDirty = dirtyChildren.has(childNode) + + if (!isDirty && cumHeightShift === 0) { + if (nodeCache.has(childElem)) { + continue + } + // Uncached = culled last frame, now re-entering. blit + // never painted it → fall through to yoga + render. + // Height unchanged (clean), so cumHeightShift stays 0. + } + + const cy = childElem.yogaNode + + if (!cy) { + continue + } + + const childTop = cy.getComputedTop() + const childH = cy.getComputedHeight() + const childBottom = childTop + childH + + if (isDirty) { + const prev = nodeCache.get(childElem) + cumHeightShift += childH - (prev ? prev.height : 0) + } + + // Skip culled children (outside viewport) + if (childBottom <= scrollTop || childTop >= scrollTop + innerHeight) { + continue + } + + // Skip children entirely within edge rows (already rendered) + if (childTop >= edgeTopLocal && childBottom <= edgeBottomLocal) { + continue + } + + const screenY = Math.floor(contentY + childTop) + + // Clean children reaching here have cumHeightShift ≠ 0 OR + // no cache. Re-check precisely: cached.y − delta is where + // the shift left old pixels; if it equals new screenY the + // blit is correct (shift re-balanced at this child, or + // yogaTop happens to net out). No cache → blit never + // painted it → render. + if (!isDirty) { + const childCached = nodeCache.get(childElem) + + if (childCached && Math.floor(childCached.y) - delta === screenY) { + continue + } + } + + // Wipe this child's region with spaces to overwrite stale + // blitted content — output.clear() only expands damage and + // cannot zero cells that the blit already wrote. + const screenBottom = Math.min( + Math.floor(contentY + childBottom), + Math.floor((y1 ?? y) + padTop + innerHeight) + ) + + if (screenY < screenBottom) { + const fill = Array(screenBottom - screenY) + .fill(spaces) + .join('\n') + + output.write(Math.floor(x), screenY, fill) + output.clip({ + x1: undefined, + x2: undefined, + y1: screenY, + y2: screenBottom + }) + renderNodeToOutput(childElem, output, { + offsetX: contentX, + offsetY: contentY, + prevScreen: undefined, + inheritedBackgroundColor: boxBackgroundColor + }) + output.unclip() + } + } + } + + // Third pass: repair rows where shifted copies of absolute + // overlays landed. The blit copied prevScreen cells INCLUDING + // overlay pixels (overlays render AFTER this ScrollBox so they + // painted into prevScreen's scroll region). After shift, those + // pixels sit at (rect.y - delta) — neither edge render nor the + // overlay's own re-render covers them. Wipe and re-render + // ScrollBox content so the diff writes correct cells. + const spaces = absoluteRectsPrev.length ? ' '.repeat(w) : '' + + for (const r of absoluteRectsPrev) { + if (r.y >= bottom + 1 || r.y + r.height <= top) { + continue + } + + const shiftedTop = Math.max(top, Math.floor(r.y) - delta) + + const shiftedBottom = Math.min(bottom + 1, Math.floor(r.y + r.height) - delta) + + // Skip if entirely within edge rows (already rendered). + if (shiftedTop >= edgeTop && shiftedBottom <= edgeBottom + 1) { + continue + } + + if (shiftedTop >= shiftedBottom) { + continue + } + + const fill = Array(shiftedBottom - shiftedTop) + .fill(spaces) + .join('\n') + + output.write(Math.floor(x), shiftedTop, fill) + output.clip({ + x1: undefined, + x2: undefined, + y1: shiftedTop, + y2: shiftedBottom + }) + renderScrolledChildren( + content, + output, + contentX, + contentY, + hasRemovedChild, + undefined, + shiftedTop - contentY, + shiftedBottom - contentY, + boxBackgroundColor, + true + ) + output.unclip() + } + } else { + // Full path. Two sub-cases: + // + // Scrolled without a usable hint (big jump, container moved): + // child positions in prevScreen are stale. Clear the viewport + // and disable blit so children don't restore shifted content. + // + // No scroll (spinner tick, content edit): child positions in + // prevScreen are still valid. Skip the viewport clear and pass + // prevScreen so unchanged children blit. Dirty children already + // self-clear via their own cached-rect clear. Without this, a + // spinner inside ScrollBox forces a full-content rewrite every + // frame — on wide terminals over tmux (no BSU/ESU) the + // bandwidth crosses the chunk boundary and the frame tears. + const scrolled = contentCached && contentCached.y !== contentY + + if (scrolled && y1 !== undefined && y2 !== undefined) { + output.clear({ + x: Math.floor(x), + y: Math.floor(y1), + width: Math.floor(width), + height: Math.floor(y2 - y1) + }) + } + + // positionChanged (ScrollBox height shrunk — pill mount) means a + // child spanning the old bottom edge would blit its full cached + // rect past the new clip. output.ts clips blits now, but also + // disable prevScreen here so the partial-row child re-renders at + // correct bounds instead of blitting a clipped (truncated) old + // rect. + renderScrolledChildren( + content, + output, + contentX, + contentY, + hasRemovedChild, + scrolled || positionChanged ? undefined : prevScreen, + scrollTop, + scrollTop + innerHeight, + boxBackgroundColor + ) + } + + nodeCache.set(content, { + x: contentX, + y: contentY, + width: contentYoga.getComputedWidth(), + height: contentYoga.getComputedHeight() + }) + content.dirty = false + } + } else { + // Fill interior with background color before rendering children. + // This covers padding areas and empty space; child text inherits + // the color via inheritedBackgroundColor so written cells also + // get the background. + // Disable prevScreen for children: the fill overwrites the entire + // interior each render, so child blits from prevScreen would restore + // stale cells (wrong bg if it changed) on top of the fresh fill. + const ownBackgroundColor = node.style.backgroundColor + + if (ownBackgroundColor || node.style.opaque) { + const borderLeft = yogaNode.getComputedBorder(LayoutEdge.Left) + const borderRight = yogaNode.getComputedBorder(LayoutEdge.Right) + const borderTop = yogaNode.getComputedBorder(LayoutEdge.Top) + const borderBottom = yogaNode.getComputedBorder(LayoutEdge.Bottom) + const innerWidth = Math.floor(width) - borderLeft - borderRight + const innerHeight = Math.floor(height) - borderTop - borderBottom + + if (innerWidth > 0 && innerHeight > 0) { + const spaces = ' '.repeat(innerWidth) + + const fillLine = ownBackgroundColor + ? applyTextStyles(spaces, { backgroundColor: ownBackgroundColor }) + : spaces + + const fill = Array(innerHeight).fill(fillLine).join('\n') + output.write(x + borderLeft, y + borderTop, fill) + } + } + + renderChildren( + node, + output, + x, + y, + hasRemovedChild, + // backgroundColor and opaque both disable child blit: the fill + // overwrites the entire interior each render, so any child whose + // layout position shifted would blit stale cells from prevScreen + // on top of the fresh fill. Previously opaque kept blit enabled + // on the assumption that plain-space fill + unchanged children = + // valid composite, but children CAN reposition (ScrollBox remeasure + // on re-render → /permissions body blanked on Down arrow, #25436). + ownBackgroundColor || node.style.opaque ? undefined : prevScreen, + boxBackgroundColor + ) + } + + if (needsClip) { + output.unclip() + } + + // Render border AFTER children to ensure it's not overwritten by child + // clearing operations. When a child shrinks, it clears its old area, + // which may overlap with where the parent's border now is. + renderBorder(x, y, node, output) + } else if (node.nodeName === 'ink-root') { + renderChildren(node, output, x, y, hasRemovedChild, prevScreen, inheritedBackgroundColor) + } + + // Cache layout bounds for dirty tracking + const rect = { x, y, width, height, top: yogaTop } + nodeCache.set(node, rect) + + if (node.style.position === 'absolute') { + absoluteRectsCur.push(rect) + } + + node.dirty = false + } +} + +// Overflow contamination: content overflows right/down, so clean siblings +// AFTER a dirty/removed sibling can contain stale overflow in prevScreen. +// Disable blit for siblings after a dirty child — but still pass prevScreen +// TO the dirty child itself so its clean descendants can blit. The dirty +// child's own blit check already fails (node.dirty=true at line 216), so +// passing prevScreen only benefits its subtree. +// For removed children we don't know their original position, so +// conservatively disable blit for all. +// +// Clipped children (overflow hidden/scroll on both axes) cannot overflow +// onto later siblings — their content is confined to their layout bounds. +// Skip the contamination guard for them so later siblings can still blit. +// Without this, a spinner inside a ScrollBox dirties the wrapper on every +// tick and the bottom prompt section never blits → 100% writes every frame. +// +// Exception: absolute-positioned clipped children may have layout bounds +// that overlap arbitrary siblings, so the clipping does not help. +// +// Overlap contamination (seenDirtyClipped): a later ABSOLUTE sibling whose +// rect sits inside a dirty clipped child's bounds would blit stale cells +// from prevScreen — the clipped child just rewrote those cells this frame. +// The clipsBothAxes skip only protects against OVERFLOW (clipped child +// painting outside its bounds), not overlap (absolute sibling painting +// inside them). For non-opaque absolute siblings, skipSelfBlit forces +// descent (the full-width rect has transparent gaps → stale blit) while +// still passing prevScreen so opaque descendants can blit their narrower +// rects (NewMessagesPill's inner Text with backgroundColor). Opaque +// absolute siblings fill their entire rect — direct blit is safe. +function renderChildren( + node: DOMElement, + output: Output, + offsetX: number, + offsetY: number, + hasRemovedChild: boolean, + prevScreen: Screen | undefined, + inheritedBackgroundColor: Color | undefined +): void { + let seenDirtyChild = false + let seenDirtyClipped = false + + for (const childNode of node.childNodes) { + const childElem = childNode as DOMElement + // Capture dirty before rendering — renderNodeToOutput clears the flag + const wasDirty = childElem.dirty + const isAbsolute = childElem.style.position === 'absolute' + renderNodeToOutput(childElem, output, { + offsetX, + offsetY, + prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen, + // Short-circuits on seenDirtyClipped (false in the common case) so + // the opaque/bg reads don't happen per-child per-frame. + skipSelfBlit: + seenDirtyClipped && isAbsolute && !childElem.style.opaque && childElem.style.backgroundColor === undefined, + inheritedBackgroundColor + }) + + if (wasDirty && !seenDirtyChild) { + if (!clipsBothAxes(childElem) || isAbsolute) { + seenDirtyChild = true + } else { + seenDirtyClipped = true + } + } + } +} + +function clipsBothAxes(node: DOMElement): boolean { + const ox = node.style.overflowX ?? node.style.overflow + const oy = node.style.overflowY ?? node.style.overflow + + return (ox === 'hidden' || ox === 'scroll') && (oy === 'hidden' || oy === 'scroll') +} + +// When Yoga squeezes a box to h=0, the ghost only happens if a sibling +// lands at the same computed top — then both write to that row and the +// shorter content leaves the longer's tail visible. Yoga's pixel-grid +// rounding can give h=0 while still advancing the next sibling's top +// (HelpV2's third shortcuts column), so h=0 alone isn't sufficient. +function siblingSharesY(node: DOMElement, yogaNode: LayoutNode): boolean { + const parent = node.parentNode + + if (!parent) { + return false + } + + const myTop = yogaNode.getComputedTop() + const siblings = parent.childNodes + const idx = siblings.indexOf(node) + + for (let i = idx + 1; i < siblings.length; i++) { + const sib = (siblings[i] as DOMElement).yogaNode + + if (!sib) { + continue + } + + return sib.getComputedTop() === myTop + } + + // No next sibling with a yoga node — check previous. A run of h=0 boxes + // at the tail would all share y with each other. + for (let i = idx - 1; i >= 0; i--) { + const sib = (siblings[i] as DOMElement).yogaNode + + if (!sib) { + continue + } + + return sib.getComputedTop() === myTop + } + + return false +} + +// When a node blits, its absolute-positioned descendants that paint outside +// the node's layout bounds are NOT covered by the blit (which only copies +// the node's own rect). If a dirty sibling re-rendered and overwrote those +// cells, we must re-blit them from prevScreen so the overlays survive. +// Example: PromptInputFooter's slash menu uses position='absolute' bottom='100%' +// to float above the prompt; a spinner tick in the ScrollBox above re-renders +// and overwrites those cells. Without this, the menu vanishes on the next frame. +function blitEscapingAbsoluteDescendants( + node: DOMElement, + output: Output, + prevScreen: Screen, + px: number, + py: number, + pw: number, + ph: number +): void { + const pr = px + pw + const pb = py + ph + + for (const child of node.childNodes) { + if (child.nodeName === '#text') { + continue + } + + const elem = child as DOMElement + + if (elem.style.position === 'absolute') { + const cached = nodeCache.get(elem) + + if (cached) { + absoluteRectsCur.push(cached) + const cx = Math.floor(cached.x) + const cy = Math.floor(cached.y) + const cw = Math.floor(cached.width) + const ch = Math.floor(cached.height) + + // Only blit rects that extend outside the parent's layout bounds — + // cells within the parent rect are already covered by the parent blit. + if (cx < px || cy < py || cx + cw > pr || cy + ch > pb) { + output.blit(prevScreen, cx, cy, cw, ch) + } + } + } + + // Recurse — absolute descendants can be nested arbitrarily deep + blitEscapingAbsoluteDescendants(elem, output, prevScreen, px, py, pw, ph) + } +} + +// Render children of a scroll container with viewport culling. +// scrollTopY..scrollBottomY are the visible window in CHILD-LOCAL Yoga coords +// (i.e. what getComputedTop() returns). Children entirely outside this window +// are skipped; their nodeCache entry is deleted so if they re-enter the +// viewport later they don't emit a stale clear for a position now occupied +// by a sibling. +function renderScrolledChildren( + node: DOMElement, + output: Output, + offsetX: number, + offsetY: number, + hasRemovedChild: boolean, + prevScreen: Screen | undefined, + scrollTopY: number, + scrollBottomY: number, + inheritedBackgroundColor: Color | undefined, + // When true (DECSTBM fast path), culled children keep their cache — + // the blit+shift put stable rows in next.screen so stale cache is + // never read. Avoids walking O(total_children * subtree_depth) per frame. + preserveCulledCache = false +): void { + let seenDirtyChild = false + // Track cumulative height shift of dirty children iterated so far. When + // zero, a clean child's yogaTop is unchanged (no sibling above it grew), + // so cached.top is fresh and the cull check skips yoga. Bottom-append + // has the dirty child last → all prior clean children hit cache → + // O(dirty) not O(mounted). Middle-growth leaves shift non-zero after + // the dirty child → subsequent children yoga-read (needed for correct + // culling since their yogaTop shifted). + let cumHeightShift = 0 + + for (const childNode of node.childNodes) { + const childElem = childNode as DOMElement + const cy = childElem.yogaNode + + if (cy) { + const cached = nodeCache.get(childElem) + let top: number + let height: number + + if (cached?.top !== undefined && !childElem.dirty && cumHeightShift === 0) { + top = cached.top + height = cached.height + } else { + top = cy.getComputedTop() + height = cy.getComputedHeight() + + if (childElem.dirty) { + cumHeightShift += height - (cached ? cached.height : 0) + } + + // Refresh cached top so next frame's cumShift===0 path stays + // correct. For culled children with preserveCulledCache=true this + // is the ONLY refresh point — without it, a middle-growth frame + // leaves stale tops that misfire next frame. + if (cached) { + cached.top = top + } + } + + const bottom = top + height + + if (bottom <= scrollTopY || top >= scrollBottomY) { + // Culled — outside visible window. Drop stale cache entries from + // the subtree so when this child re-enters it doesn't fire clears + // at positions now occupied by siblings. The viewport-clear on + // scroll-change handles the visible-area repaint. + if (!preserveCulledCache) { + dropSubtreeCache(childElem) + } + + continue + } + } + + const wasDirty = childElem.dirty + renderNodeToOutput(childElem, output, { + offsetX, + offsetY, + prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen, + inheritedBackgroundColor + }) + + if (wasDirty) { + seenDirtyChild = true + } + } +} + +function dropSubtreeCache(node: DOMElement): void { + nodeCache.delete(node) + + for (const child of node.childNodes) { + if (child.nodeName !== '#text') { + dropSubtreeCache(child as DOMElement) + } + } +} + +// Exported for testing +export { applyStylesToWrappedText, buildCharToSegmentMap } + +export default renderNodeToOutput diff --git a/ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts b/ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts new file mode 100644 index 0000000000..bee9f8f1c5 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts @@ -0,0 +1,241 @@ +import noop from 'lodash-es/noop.js' +import type { ReactElement } from 'react' +import { LegacyRoot } from 'react-reconciler/constants.js' + +import { logForDebugging } from '../utils/debug.js' + +import { createNode, type DOMElement } from './dom.js' +import { FocusManager } from './focus.js' +import Output from './output.js' +import reconciler from './reconciler.js' +import renderNodeToOutput, { resetLayoutShifted } from './render-node-to-output.js' +import { + cellAtIndex, + CellWidth, + CharPool, + createScreen, + HyperlinkPool, + type Screen, + setCellStyleId, + StylePool +} from './screen.js' + +/** Position of a match within a rendered message, relative to the message's + * own bounding box (row 0 = message top). Stable across scroll — to + * highlight on the real screen, add the message's screen-row offset. */ +export type MatchPosition = { + row: number + col: number + /** Number of CELLS the match spans (= query.length for ASCII, more + * for wide chars in the query). */ + len: number +} + +// Shared across calls. Pools accumulate style/char interns — reusing them +// means later calls hit cache more. Root/container reuse saves the +// createContainer cost (~1ms). LegacyRoot: all work sync, no scheduling — +// ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork. +let root: DOMElement | undefined +let container: ReturnType | undefined +let stylePool: StylePool | undefined +let charPool: CharPool | undefined +let hyperlinkPool: HyperlinkPool | undefined +let output: Output | undefined + +const timing = { reconcile: 0, yoga: 0, paint: 0, scan: 0, calls: 0 } +const LOG_EVERY = 20 + +/** Render a React element (wrapped in all contexts the component needs — + * caller's job) to an isolated Screen buffer at the given width. Returns + * the Screen + natural height (from yoga). Used for search: render ONE + * message, scan its Screen for the query, get exact (row, col) positions. + * + * ~1-3ms per call (yoga alloc + calculateLayout + paint). The + * flushSyncWork cross-root leak measured ~0.0003ms/call growth — fine + * for on-demand single-message rendering, pathological for render-all- + * 8k-upfront. Cache per (msg, query, width) upstream. + * + * Unmounts between calls. Root/container/pools persist for reuse. */ +export function renderToScreen(el: ReactElement, width: number): { screen: Screen; height: number } { + if (!root) { + root = createNode('ink-root') + root.focusManager = new FocusManager(() => false) + stylePool = new StylePool() + charPool = new CharPool() + hyperlinkPool = new HyperlinkPool() + // @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11 + container = reconciler.createContainer(root, LegacyRoot, null, false, null, 'search-render', noop, noop, noop, noop) + } + + const t0 = performance.now() + // @ts-expect-error updateContainerSync exists but not in @types + reconciler.updateContainerSync(el, container, null, noop) + // @ts-expect-error flushSyncWork exists but not in @types + reconciler.flushSyncWork() + const t1 = performance.now() + + // Yoga layout. Root might not have a yogaNode if the tree is empty. + root.yogaNode?.setWidth(width) + root.yogaNode?.calculateLayout(width) + const height = Math.ceil(root.yogaNode?.getComputedHeight() ?? 0) + const t2 = performance.now() + + // Paint to a fresh Screen. Width = given, height = yoga's natural. + // No alt-screen, no prevScreen (every call is fresh). + const screen = createScreen( + width, + Math.max(1, height), // avoid 0-height Screen (createScreen may choke) + stylePool!, + charPool!, + hyperlinkPool! + ) + + if (!output) { + output = new Output({ width, height, stylePool: stylePool!, screen }) + } else { + output.reset(width, height, screen) + } + + resetLayoutShifted() + renderNodeToOutput(root, output, { prevScreen: undefined }) + // renderNodeToOutput queues writes into Output; .get() flushes the + // queue into the Screen's cell arrays. Without this the screen is + // blank (constructor-zero). + const rendered = output.get() + const t3 = performance.now() + + // Unmount so next call gets a fresh tree. Leaves root/container/pools. + // @ts-expect-error updateContainerSync exists but not in @types + reconciler.updateContainerSync(null, container, null, noop) + // @ts-expect-error flushSyncWork exists but not in @types + reconciler.flushSyncWork() + + timing.reconcile += t1 - t0 + timing.yoga += t2 - t1 + timing.paint += t3 - t2 + + if (++timing.calls % LOG_EVERY === 0) { + const total = timing.reconcile + timing.yoga + timing.paint + timing.scan + logForDebugging( + `renderToScreen: ${timing.calls} calls · ` + + `reconcile=${timing.reconcile.toFixed(1)}ms yoga=${timing.yoga.toFixed(1)}ms ` + + `paint=${timing.paint.toFixed(1)}ms scan=${timing.scan.toFixed(1)}ms · ` + + `total=${total.toFixed(1)}ms · avg ${(total / timing.calls).toFixed(2)}ms/call` + ) + } + + return { screen: rendered, height } +} + +/** Scan a Screen buffer for all occurrences of query. Returns positions + * relative to the buffer (row 0 = buffer top). Same cell-skip logic as + * applySearchHighlight (SpacerTail/SpacerHead/noSelect) so positions + * match what the overlay highlight would find. Case-insensitive. + * + * For the side-render use: this Screen is the FULL message (natural + * height, not viewport-clipped). Positions are stable — to highlight + * on the real screen, add the message's screen offset (lo). */ +export function scanPositions(screen: Screen, query: string): MatchPosition[] { + const lq = query.toLowerCase() + + if (!lq) { + return [] + } + + const qlen = lq.length + const w = screen.width + const h = screen.height + const noSelect = screen.noSelect + const positions: MatchPosition[] = [] + + const t0 = performance.now() + + for (let row = 0; row < h; row++) { + const rowOff = row * w + // Same text-build as applySearchHighlight. Keep in sync — or extract + // to a shared helper (TODO once both are stable). codeUnitToCell + // maps indexOf positions (code units in the LOWERCASED text) to cell + // indices in colOf — surrogate pairs (emoji) and multi-unit lowercase + // (Turkish İ → i + U+0307) make text.length > colOf.length. + let text = '' + const colOf: number[] = [] + const codeUnitToCell: number[] = [] + + for (let col = 0; col < w; col++) { + const idx = rowOff + col + const cell = cellAtIndex(screen, idx) + + if (cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead || noSelect[idx] === 1) { + continue + } + + const lc = cell.char.toLowerCase() + const cellIdx = colOf.length + + for (let i = 0; i < lc.length; i++) { + codeUnitToCell.push(cellIdx) + } + + text += lc + colOf.push(col) + } + + // Non-overlapping — same advance as applySearchHighlight. + let pos = text.indexOf(lq) + + while (pos >= 0) { + const startCi = codeUnitToCell[pos]! + const endCi = codeUnitToCell[pos + qlen - 1]! + const col = colOf[startCi]! + const endCol = colOf[endCi]! + 1 + positions.push({ row, col, len: endCol - col }) + pos = text.indexOf(lq, pos + qlen) + } + } + + timing.scan += performance.now() - t0 + + return positions +} + +/** Write CURRENT (yellow+bold+underline) at positions[currentIdx] + + * rowOffset. OTHER positions are NOT styled here — the scan-highlight + * (applySearchHighlight with null hint) does inverse for all visible + * matches, including these. Two-layer: scan = 'you could go here', + * position = 'you ARE here'. Writing inverse again here would be a + * no-op (withInverse idempotent) but wasted work. + * + * Positions are message-relative (row 0 = message top). rowOffset = + * message's current screen-top (lo). Clips outside [0, height). */ +export function applyPositionedHighlight( + screen: Screen, + stylePool: StylePool, + positions: MatchPosition[], + rowOffset: number, + currentIdx: number +): boolean { + if (currentIdx < 0 || currentIdx >= positions.length) { + return false + } + + const p = positions[currentIdx]! + const row = p.row + rowOffset + + if (row < 0 || row >= screen.height) { + return false + } + + const transform = (id: number) => stylePool.withCurrentMatch(id) + const rowOff = row * screen.width + + for (let col = p.col; col < p.col + p.len; col++) { + if (col < 0 || col >= screen.width) { + continue + } + + const cell = cellAtIndex(screen, rowOff + col) + setCellStyleId(screen, col, row, transform(cell.styleId)) + } + + return true +} diff --git a/ui-tui/packages/hermes-ink/src/ink/renderer.ts b/ui-tui/packages/hermes-ink/src/ink/renderer.ts new file mode 100644 index 0000000000..ca89182d7e --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/renderer.ts @@ -0,0 +1,167 @@ +import { logForDebugging } from '../utils/debug.js' + +import { type DOMElement, markDirty } from './dom.js' +import type { Frame } from './frame.js' +import { consumeAbsoluteRemovedFlag } from './node-cache.js' +import Output from './output.js' +import renderNodeToOutput, { + getScrollDrainNode, + getScrollHint, + resetLayoutShifted, + resetScrollDrainNode, + resetScrollHint +} from './render-node-to-output.js' +import { createScreen, type StylePool } from './screen.js' + +export type RenderOptions = { + frontFrame: Frame + backFrame: Frame + isTTY: boolean + terminalWidth: number + terminalRows: number + altScreen: boolean + // True when the previous frame's screen buffer was mutated post-render + // (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT), + // or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would + // copy stale inverted cells, blanks, or nothing. When false, blit is safe. + prevFrameContaminated: boolean +} + +export type Renderer = (options: RenderOptions) => Frame + +export default function createRenderer(node: DOMElement, stylePool: StylePool): Renderer { + // Reuse Output across frames so charCache (tokenize + grapheme clustering) + // persists — most lines don't change between renders. + let output: Output | undefined + + return options => { + const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } = options + + const prevScreen = frontFrame.screen + const backScreen = backFrame.screen + // Read pools from the back buffer's screen — pools may be replaced + // between frames (generational reset), so we can't capture them in the closure + const charPool = backScreen.charPool + const hyperlinkPool = backScreen.hyperlinkPool + + // Return empty frame if yoga node doesn't exist or layout hasn't been computed yet. + // getComputedHeight() returns NaN before calculateLayout() is called. + // Also check for invalid dimensions (negative, Infinity) that would cause RangeError + // when creating arrays. + const computedHeight = node.yogaNode?.getComputedHeight() + const computedWidth = node.yogaNode?.getComputedWidth() + + const hasInvalidHeight = computedHeight === undefined || !Number.isFinite(computedHeight) || computedHeight < 0 + + const hasInvalidWidth = computedWidth === undefined || !Number.isFinite(computedWidth) || computedWidth < 0 + + if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) { + // Log to help diagnose root cause (visible with --debug flag) + if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) { + logForDebugging( + `Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` + + `childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}` + ) + } + + return { + screen: createScreen(terminalWidth, 0, stylePool, charPool, hyperlinkPool), + viewport: { width: terminalWidth, height: terminalRows }, + cursor: { x: 0, y: 0, visible: true } + } + } + + const width = Math.floor(node.yogaNode.getComputedWidth()) + const yogaHeight = Math.floor(node.yogaNode.getComputedHeight()) + // Alt-screen: the screen buffer IS the alt buffer — always exactly + // terminalRows tall. wraps children in , so yogaHeight should equal + // terminalRows. But if something renders as a SIBLING of that Box + // (bug: MessageSelector was outside ), yogaHeight + // exceeds rows and every assumption below (viewport +1 hack, cursor.y + // clamp, log-update's heightDelta===0 fast path) breaks, desyncing + // virtual/physical cursors. Clamping here enforces the invariant: + // overflow writes land at y >= screen.height and setCellAt drops + // them. The sibling is invisible (obvious, easy to find) instead of + // corrupting the whole terminal. + const height = options.altScreen ? terminalRows : yogaHeight + + if (options.altScreen && yogaHeight > terminalRows) { + logForDebugging( + `alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` + + `something is rendering outside . Overflow clipped.`, + { level: 'warn' } + ) + } + + const screen = backScreen ?? createScreen(width, height, stylePool, charPool, hyperlinkPool) + + if (output) { + output.reset(width, height, screen) + } else { + output = new Output({ width, height, stylePool, screen }) + } + + resetLayoutShifted() + resetScrollHint() + resetScrollDrainNode() + + // prevFrameContaminated: selection overlay mutated the returned screen + // buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it + // with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame + // would copy stale inverted cells / blanks / nothing. When clean, blit + // restores the O(unchanged) fast path for steady-state frames (spinner + // tick, text stream). + // Removing an absolute-positioned node poisons prevScreen: it may + // have painted over non-siblings (e.g. an overlay over a ScrollBox + // earlier in tree order), so their blits would restore the removed + // node's pixels. hasRemovedChild only shields direct siblings. + // Normal-flow removals don't paint cross-subtree and are fine. + const absoluteRemoved = consumeAbsoluteRemovedFlag() + renderNodeToOutput(node, output, { + prevScreen: absoluteRemoved || options.prevFrameContaminated ? undefined : prevScreen + }) + + const renderedScreen = output.get() + + // Drain continuation: render cleared scrollbox.dirty, so next frame's + // root blit would skip the subtree. markDirty walks ancestors so the + // next frame descends. Done AFTER render so the clear-dirty at the end + // of renderNodeToOutput doesn't overwrite this. + const drainNode = getScrollDrainNode() + + if (drainNode) { + markDirty(drainNode) + } + + return { + scrollHint: options.altScreen ? getScrollHint() : null, + scrollDrainPending: drainNode !== null, + screen: renderedScreen, + viewport: { + width: terminalWidth, + // Alt screen: fake viewport.height = rows + 1 so that + // shouldClearScreen()'s `screen.height >= viewport.height` check + // (which treats exactly-filling content as "overflows" for + // scrollback purposes) never fires. Alt-screen content is always + // exactly `rows` tall (via ) but never + // scrolls — the cursor.y clamp below keeps the cursor-restore + // from emitting an LF. With the standard diff path, every frame + // is incremental; no fullResetSequence_CAUSES_FLICKER. + height: options.altScreen ? terminalRows + 1 : terminalRows + }, + cursor: { + x: 0, + // In the alt screen, keep the cursor inside the viewport. When + // screen.height === terminalRows exactly (content fills the alt + // screen), cursor.y = screen.height would trigger log-update's + // cursor-restore LF at the last row, scrolling one row off the top + // of the alt buffer and desyncing the diff's cursor model. The + // cursor is hidden so its position only matters for diff coords. + y: options.altScreen ? Math.max(0, Math.min(screen.height, terminalRows) - 1) : screen.height, + // Hide cursor when there's dynamic output to render (only in TTY mode) + visible: !isTTY || screen.height === 0 + } + } + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/root.ts b/ui-tui/packages/hermes-ink/src/ink/root.ts new file mode 100644 index 0000000000..27ace59a6b --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/root.ts @@ -0,0 +1,174 @@ +import { Stream } from 'stream' + +import type { ReactNode } from 'react' + +import { logForDebugging } from '../utils/debug.js' + +import type { FrameEvent } from './frame.js' +import Ink, { type Options as InkOptions } from './ink.js' +import instances from './instances.js' + +export type RenderOptions = { + /** + * Output stream where app will be rendered. + * + * @default process.stdout + */ + stdout?: NodeJS.WriteStream + /** + * Input stream where app will listen for input. + * + * @default process.stdin + */ + stdin?: NodeJS.ReadStream + /** + * Error stream. + * @default process.stderr + */ + stderr?: NodeJS.WriteStream + /** + * Configure whether Ink should listen to Ctrl+C keyboard input and exit the app. This is needed in case `process.stdin` is in raw mode, because then Ctrl+C is ignored by default and process is expected to handle it manually. + * + * @default true + */ + exitOnCtrlC?: boolean + + /** + * Patch console methods to ensure console output doesn't mix with Ink output. + * + * @default true + */ + patchConsole?: boolean + + /** + * Called after each frame render with timing and flicker information. + */ + onFrame?: (event: FrameEvent) => void +} + +export type Instance = { + /** + * Replace previous root node with a new one or update props of the current root node. + */ + rerender: Ink['render'] + /** + * Manually unmount the whole Ink app. + */ + unmount: Ink['unmount'] + /** + * Returns a promise, which resolves when app is unmounted. + */ + waitUntilExit: Ink['waitUntilExit'] + cleanup: () => void +} + +/** + * A managed Ink root, similar to react-dom's createRoot API. + * Separates instance creation from rendering so the same root + * can be reused for multiple sequential screens. + */ +export type Root = { + render: (node: ReactNode) => void + unmount: () => void + waitUntilExit: () => Promise +} + +/** + * Mount a component and render the output. + */ +export const renderSync = (node: ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance => { + const opts = getOptions(options) + + const inkOptions: InkOptions = { + stdout: process.stdout, + stdin: process.stdin, + stderr: process.stderr, + exitOnCtrlC: true, + patchConsole: true, + ...opts + } + + const instance: Ink = getInstance(inkOptions.stdout, () => new Ink(inkOptions)) + + instance.render(node) + + return { + rerender: instance.render, + unmount() { + instance.unmount() + }, + waitUntilExit: instance.waitUntilExit, + cleanup: () => instances.delete(inkOptions.stdout) + } +} + +const wrappedRender = async (node: ReactNode, options?: NodeJS.WriteStream | RenderOptions): Promise => { + // Preserve the microtask boundary that `await loadYoga()` used to provide. + // Without it, the first render fires synchronously before async startup work + // (e.g. useReplBridge notification state) settles, and the subsequent Static + // write overwrites scrollback instead of appending below the logo. + await Promise.resolve() + const instance = renderSync(node, options) + logForDebugging(`[render] first ink render: ${Math.round(process.uptime() * 1000)}ms since process start`) + + return instance +} + +export default wrappedRender + +/** + * Create an Ink root without rendering anything yet. + * Like react-dom's createRoot — call root.render() to mount a tree. + */ +export async function createRoot({ + stdout = process.stdout, + stdin = process.stdin, + stderr = process.stderr, + exitOnCtrlC = true, + patchConsole = true, + onFrame +}: RenderOptions = {}): Promise { + // See wrappedRender — preserve microtask boundary from the old WASM await. + await Promise.resolve() + + const instance = new Ink({ + stdout, + stdin, + stderr, + exitOnCtrlC, + patchConsole, + onFrame + }) + + // Register in the instances map so that code that looks up the Ink + // instance by stdout (e.g. external editor pause/resume) can find it. + instances.set(stdout, instance) + + return { + render: node => instance.render(node), + unmount: () => instance.unmount(), + waitUntilExit: () => instance.waitUntilExit() + } +} + +const getOptions = (stdout: NodeJS.WriteStream | RenderOptions | undefined = {}): RenderOptions => { + if (stdout instanceof Stream) { + return { + stdout, + stdin: process.stdin + } + } + + return stdout +} + +const getInstance = (stdout: NodeJS.WriteStream, createInstance: () => Ink): Ink => { + let instance = instances.get(stdout) + + if (!instance) { + instance = createInstance() + instances.set(stdout, instance) + } + + return instance +} diff --git a/ui-tui/packages/hermes-ink/src/ink/screen.ts b/ui-tui/packages/hermes-ink/src/ink/screen.ts new file mode 100644 index 0000000000..5a9b9df229 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/screen.ts @@ -0,0 +1,1543 @@ +import { type AnsiCode, ansiCodesToString, diffAnsiCodes } from '@alcalzone/ansi-tokenize' + +import { type Point, type Rectangle, type Size, unionRect } from './layout/geometry.js' +import { BEL, ESC, SEP } from './termio/ansi.js' +import * as warn from './warn.js' + +// --- Shared Pools (interning for memory efficiency) --- + +// Character string pool shared across all screens. +// With a shared pool, interned char IDs are valid across screens, +// so blitRegion can copy IDs directly (no re-interning) and +// diffEach can compare IDs as integers (no string lookup). +export class CharPool { + private strings: string[] = [' ', ''] // Index 0 = space, 1 = empty (spacer) + private stringMap = new Map([ + [' ', 0], + ['', 1] + ]) + private ascii: Int32Array = initCharAscii() // charCode → index, -1 = not interned + + intern(char: string): number { + // ASCII fast-path: direct array lookup instead of Map.get + if (char.length === 1) { + const code = char.charCodeAt(0) + + if (code < 128) { + const cached = this.ascii[code]! + + if (cached !== -1) { + return cached + } + + const index = this.strings.length + this.strings.push(char) + this.ascii[code] = index + + return index + } + } + + const existing = this.stringMap.get(char) + + if (existing !== undefined) { + return existing + } + + const index = this.strings.length + this.strings.push(char) + this.stringMap.set(char, index) + + return index + } + + get(index: number): string { + return this.strings[index] ?? ' ' + } +} + +// Hyperlink string pool shared across all screens. +// Index 0 = no hyperlink. +export class HyperlinkPool { + private strings: string[] = [''] // Index 0 = no hyperlink + private stringMap = new Map() + + intern(hyperlink: string | undefined): number { + if (!hyperlink) { + return 0 + } + + let id = this.stringMap.get(hyperlink) + + if (id === undefined) { + id = this.strings.length + this.strings.push(hyperlink) + this.stringMap.set(hyperlink, id) + } + + return id + } + + get(id: number): string | undefined { + return id === 0 ? undefined : this.strings[id] + } +} + +// SGR 7 (inverse) as an AnsiCode. endCode '\x1b[27m' flags VISIBLE_ON_SPACE +// so bit 0 of the resulting styleId is set → renderer won't skip inverted +// spaces as invisible. +const INVERSE_CODE: AnsiCode = { + type: 'ansi', + code: '\x1b[7m', + endCode: '\x1b[27m' +} + +// Bold (SGR 1) — stacks cleanly, no reflow in monospace. endCode 22 +// also cancels dim (SGR 2); harmless here since we never add dim. +const BOLD_CODE: AnsiCode = { + type: 'ansi', + code: '\x1b[1m', + endCode: '\x1b[22m' +} + +// Underline (SGR 4). Kept alongside yellow+bold — the underline is the +// unambiguous visible-on-any-theme marker. Yellow-bg-via-inverse can +// clash with existing bg colors (user-prompt style, tool chrome, syntax +// bg). If you see underline but no yellow, the yellow is being lost in +// the existing cell styling — the overlay IS finding the match. +const UNDERLINE_CODE: AnsiCode = { + type: 'ansi', + code: '\x1b[4m', + endCode: '\x1b[24m' +} + +// fg→yellow (SGR 33). With inverse already in the stack, the terminal +// swaps fg↔bg at render — so yellow-fg becomes yellow-BG. Original bg +// becomes fg (readable on most themes: dark-bg → dark-text on yellow). +// endCode 39 is 'default fg' — cancels any prior fg color cleanly. +const YELLOW_FG_CODE: AnsiCode = { + type: 'ansi', + code: '\x1b[33m', + endCode: '\x1b[39m' +} + +export class StylePool { + private ids = new Map() + private styles: AnsiCode[][] = [] + private transitionCache = new Map() + readonly none: number + + constructor() { + this.none = this.intern([]) + } + + /** + * Intern a style and return its ID. Bit 0 of the ID encodes whether the + * style has a visible effect on space characters (background, inverse, + * underline, etc.). Foreground-only styles get even IDs; styles visible + * on spaces get odd IDs. This lets the renderer skip invisible spaces + * with a single bitmask check on the packed word. + */ + intern(styles: AnsiCode[]): number { + const key = styles.length === 0 ? '' : styles.map(s => s.code).join('\0') + let id = this.ids.get(key) + + if (id === undefined) { + const rawId = this.styles.length + this.styles.push(styles.length === 0 ? [] : styles) + id = (rawId << 1) | (styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0) + this.ids.set(key, id) + } + + return id + } + + /** Recover styles from an encoded ID. Strips the bit-0 flag via >>> 1. */ + get(id: number): AnsiCode[] { + return this.styles[id >>> 1] ?? [] + } + + /** + * Returns the pre-serialized ANSI string to transition from one style to + * another. Cached by (fromId, toId) — zero allocations after first call + * for a given pair. + */ + transition(fromId: number, toId: number): string { + if (fromId === toId) { + return '' + } + + const key = fromId * 0x100000 + toId + let str = this.transitionCache.get(key) + + if (str === undefined) { + str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId))) + this.transitionCache.set(key, str) + } + + return str + } + + /** + * Intern a style that is `base + inverse`. Cached by base ID so + * repeated calls for the same underlying style don't re-scan the + * AnsiCode[] array. Used by the selection overlay. + */ + private inverseCache = new Map() + withInverse(baseId: number): number { + let id = this.inverseCache.get(baseId) + + if (id === undefined) { + const baseCodes = this.get(baseId) + // If already inverted, use as-is (avoids SGR 7 stacking) + const hasInverse = baseCodes.some(c => c.endCode === '\x1b[27m') + id = hasInverse ? baseId : this.intern([...baseCodes, INVERSE_CODE]) + this.inverseCache.set(baseId, id) + } + + return id + } + + /** Inverse + bold + yellow-bg-via-fg-swap for the CURRENT search match. + * OTHER matches are plain inverse — bg inherits from the theme. Current + * gets a distinct yellow bg (via fg-then-inverse swap) plus bold weight + * so it stands out in a sea of inverse. Underline was too subtle. Zero + * reflow risk: all pure SGR overlays, per-cell, post-layout. The yellow + * overrides any existing fg (syntax highlighting) on those cells — fine, + * the "you are here" signal IS the point, syntax color can yield. */ + private currentMatchCache = new Map() + withCurrentMatch(baseId: number): number { + let id = this.currentMatchCache.get(baseId) + + if (id === undefined) { + const baseCodes = this.get(baseId) + + // Filter BOTH fg + bg so yellow-via-inverse is unambiguous. + // User-prompt cells have an explicit bg (grey box); with that bg + // still set, inverse swaps yellow-fg↔grey-bg → grey-on-yellow on + // SOME terminals, yellow-on-grey on others (inverse semantics vary + // when both colors are explicit). Filtering both gives clean + // yellow-bg + terminal-default-fg everywhere. Bold/dim/italic + // coexist — keep those. + const codes = baseCodes.filter(c => c.endCode !== '\x1b[39m' && c.endCode !== '\x1b[49m') + + // fg-yellow FIRST so inverse swaps it to bg. Bold after inverse is + // fine — SGR 1 is fg-attribute-only, order-independent vs 7. + codes.push(YELLOW_FG_CODE) + + if (!baseCodes.some(c => c.endCode === '\x1b[27m')) { + codes.push(INVERSE_CODE) + } + + if (!baseCodes.some(c => c.endCode === '\x1b[22m')) { + codes.push(BOLD_CODE) + } + + // Underline as the unambiguous marker — yellow-bg can clash with + // existing bg styling (user-prompt bg, syntax bg). If you see + // underline but no yellow on a match, the overlay IS finding it; + // the yellow is just losing a styling fight. + if (!baseCodes.some(c => c.endCode === '\x1b[24m')) { + codes.push(UNDERLINE_CODE) + } + + id = this.intern(codes) + this.currentMatchCache.set(baseId, id) + } + + return id + } + + /** + * Selection overlay: REPLACE the cell's background with a solid color + * while preserving its foreground (color, bold, italic, dim, underline). + * Matches native terminal selection — a dedicated bg color, not SGR-7 + * inverse. Inverse swaps fg/bg per-cell, which fragments visually over + * syntax-highlighted text (every fg color becomes a different bg stripe). + * + * Strips any existing bg (endCode 49m — REPLACES, so diff-added green + * etc. don't bleed through) and any existing inverse (endCode 27m — + * inverse on top of a solid bg would re-swap and look wrong). + * + * bg is set via setSelectionBg(); null → fallback to withInverse() so the + * overlay still works before theme wiring sets a color (tests, first frame). + * Cache is keyed by baseId only — setSelectionBg() clears it on change. + */ + private selectionBgCode: AnsiCode | null = null + private selectionBgCache = new Map() + setSelectionBg(bg: AnsiCode | null): void { + if (this.selectionBgCode?.code === bg?.code) { + return + } + + this.selectionBgCode = bg + this.selectionBgCache.clear() + } + withSelectionBg(baseId: number): number { + const bg = this.selectionBgCode + + if (bg === null) { + return this.withInverse(baseId) + } + + let id = this.selectionBgCache.get(baseId) + + if (id === undefined) { + // Keep everything except bg (49m) and inverse (27m). Fg, bold, dim, + // italic, underline, strikethrough all preserved. + const kept = this.get(baseId).filter(c => c.endCode !== '\x1b[49m' && c.endCode !== '\x1b[27m') + + kept.push(bg) + id = this.intern(kept) + this.selectionBgCache.set(baseId, id) + } + + return id + } +} + +// endCodes that produce visible effects on space characters +const VISIBLE_ON_SPACE = new Set([ + '\x1b[49m', // background color + '\x1b[27m', // inverse + '\x1b[24m', // underline + '\x1b[29m', // strikethrough + '\x1b[55m' // overline +]) + +function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean { + for (const style of styles) { + if (VISIBLE_ON_SPACE.has(style.endCode)) { + return true + } + } + + return false +} + +/** + * Cell width classification for handling double-wide characters (CJK, emoji, + * etc.) + * + * We use explicit spacer cells rather than inferring width at render time. This + * makes the data structure self-describing and simplifies cursor positioning + * logic. + * + * @see https://mitchellh.com/writing/grapheme-clusters-in-terminals + */ +// const enum is inlined at compile time - no runtime object, no property access +export const enum CellWidth { + // Not a wide character, cell width 1 + Narrow = 0, + // Wide character, cell width 2. This cell contains the actual character. + Wide = 1, + // Spacer occupying the second visual column of a wide character. Do not render. + SpacerTail = 2, + // Spacer at the end of a soft-wrapped line indicating that a wide character + // continues on the next line. Used for preserving wide character semantics + // across line breaks during soft wrapping. + SpacerHead = 3 +} + +export type Hyperlink = string | undefined + +/** + * Cell is a view type returned by cellAt(). Cells are stored as packed typed + * arrays internally to avoid GC pressure from allocating objects per cell. + */ +export type Cell = { + char: string + styleId: number + width: CellWidth + hyperlink: Hyperlink +} + +// Constants for empty/spacer cells to enable fast comparisons +// These are indices into the charStrings table, not codepoints +const EMPTY_CHAR_INDEX = 0 // ' ' (space) +const SPACER_CHAR_INDEX = 1 // '' (empty string for spacer cells) +// Unwritten cells are [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0]. +// Since StylePool.none is always 0 (first intern), unwritten cells are +// indistinguishable from explicitly-cleared cells in the packed array. +// This is intentional: diffEach can compare raw ints with zero normalization. +// isEmptyCellByIndex checks if both words are 0 to identify "never visually written" cells. + +function initCharAscii(): Int32Array { + const table = new Int32Array(128) + table.fill(-1) + table[32] = EMPTY_CHAR_INDEX // ' ' (space) + + return table +} + +// --- Packed cell layout --- +// Each cell is 2 consecutive Int32 elements in the cells array: +// word0 (cells[ci]): charId (full 32 bits) +// word1 (cells[ci + 1]): styleId[31:17] | hyperlinkId[16:2] | width[1:0] +const STYLE_SHIFT = 17 +const HYPERLINK_SHIFT = 2 +const HYPERLINK_MASK = 0x7fff // 15 bits +const WIDTH_MASK = 3 // 2 bits + +// Pack styleId, hyperlinkId, and width into a single Int32 +function packWord1(styleId: number, hyperlinkId: number, width: number): number { + return (styleId << STYLE_SHIFT) | (hyperlinkId << HYPERLINK_SHIFT) | width +} + +// Unwritten cell as BigInt64 — both words are 0, so the 64-bit value is 0n. +// Used by BigInt64Array.fill() for bulk clears (resetScreen, clearRegion). +// Not used for comparison — BigInt element reads cause heap allocation. +const EMPTY_CELL_VALUE = 0n + +/** + * Screen uses a packed Int32Array instead of Cell objects to eliminate GC + * pressure. For a 200x120 screen, this avoids allocating 24,000 objects. + * + * Cell data is stored as 2 Int32s per cell in a single contiguous array: + * word0: charId (full 32 bits — index into CharPool) + * word1: styleId[31:17] | hyperlinkId[16:2] | width[1:0] + * + * This layout halves memory accesses in diffEach (2 int loads vs 4) and + * enables future SIMD comparison via Bun.indexOfFirstDifference. + */ +export type Screen = Size & { + // Packed cell data — 2 Int32s per cell: [charId, packed(styleId|hyperlinkId|width)] + // cells and cells64 are views over the same ArrayBuffer. + cells: Int32Array + cells64: BigInt64Array // 1 BigInt64 per cell — used for bulk fill in resetScreen/clearRegion + + // Shared pools — IDs are valid across all screens using the same pools + charPool: CharPool + hyperlinkPool: HyperlinkPool + + // Empty style ID for comparisons + emptyStyleId: number + + /** + * Bounding box of cells that were written to (not blitted) during rendering. + * Used by diff() to limit iteration to only the region that could have changed. + */ + damage: Rectangle | undefined + + /** + * Per-cell noSelect bitmap — 1 byte per cell, 1 = exclude from text + * selection (copy + highlight). Used by to mark gutters + * (line numbers, diff sigils) so click-drag over a diff yields clean + * copyable code. Fully reset each frame in resetScreen; blitRegion + * copies it alongside cells so the blit optimization preserves marks. + */ + noSelect: Uint8Array + + /** + * Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r + * is a word-wrap continuation of row r-1 (the `\n` before it was + * inserted by wrapAnsi, not in the source), and row r-1's written + * content ends at absolute column N (exclusive — cells [0..N) are the + * fragment, past N is unwritten padding). 0 means row r is NOT a + * continuation (hard newline or first row). Selection copy checks + * softWrap[r]>0 to join row r onto row r-1 without a newline, and + * reads softWrap[r+1] to know row r's content end when row r+1 + * continues from it. The content-end column is needed because an + * unwritten cell and a written-unstyled-space are indistinguishable in + * the packed typed array (both all-zero) — without it we'd either drop + * the word-separator space (trim) or include trailing padding (no + * trim). This encoding (continuation-on-self, prev-content-end-here) + * is chosen so shiftRows preserves the is-continuation semantics: when + * row r scrolls off the top and row r+1 shifts to row r, sw[r] gets + * old sw[r+1] — which correctly says the new row r is a continuation + * of what's now in scrolledOffAbove. Reset each frame; copied by + * blitRegion/shiftRows. + */ + softWrap: Int32Array +} + +function isEmptyCellByIndex(screen: Screen, index: number): boolean { + // An empty/unwritten cell has both words === 0: + // word0 = EMPTY_CHAR_INDEX (0), word1 = packWord1(emptyStyleId=0, 0, 0) = 0. + const ci = index << 1 + + return screen.cells[ci] === 0 && screen.cells[ci | 1] === 0 +} + +export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) { + return true + } + + return isEmptyCellByIndex(screen, y * screen.width + x) +} + +/** + * Check if a Cell (view object) represents an empty cell. + */ +export function isCellEmpty(screen: Screen, cell: Cell): boolean { + // Check if cell looks like an empty cell (space, empty style, narrow, no link). + // Note: After cellAt mapping, unwritten cells have emptyStyleId, so this + // returns true for both unwritten AND cleared cells. Use isEmptyCellAt + // for the internal distinction. + return cell.char === ' ' && cell.styleId === screen.emptyStyleId && cell.width === CellWidth.Narrow && !cell.hyperlink +} + +// Intern a hyperlink string and return its ID (0 = no hyperlink) +function internHyperlink(screen: Screen, hyperlink: Hyperlink): number { + return screen.hyperlinkPool.intern(hyperlink) +} + +// --- + +export function createScreen( + width: number, + height: number, + styles: StylePool, + charPool: CharPool, + hyperlinkPool: HyperlinkPool +): Screen { + // Warn if dimensions are not valid integers (likely bad yoga layout output) + warn.ifNotInteger(width, 'createScreen width') + warn.ifNotInteger(height, 'createScreen height') + + // Ensure width and height are valid integers to prevent crashes + if (!Number.isInteger(width) || width < 0) { + width = Math.max(0, Math.floor(width) || 0) + } + + if (!Number.isInteger(height) || height < 0) { + height = Math.max(0, Math.floor(height) || 0) + } + + const size = width * height + + // Allocate one buffer, two views: Int32Array for per-word access, + // BigInt64Array for bulk fill in resetScreen/clearRegion. + // ArrayBuffer is zero-filled, which is exactly the empty cell value: + // [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0]. + const buf = new ArrayBuffer(size << 3) // 8 bytes per cell + const cells = new Int32Array(buf) + const cells64 = new BigInt64Array(buf) + + return { + width, + height, + cells, + cells64, + charPool, + hyperlinkPool, + emptyStyleId: styles.none, + damage: undefined, + noSelect: new Uint8Array(size), + softWrap: new Int32Array(height) + } +} + +/** + * Reset an existing screen for reuse, avoiding allocation of new typed arrays. + * Resizes if needed and clears all cells to empty/unwritten state. + * + * For double-buffering, this allows swapping between front and back buffers + * without allocating new Screen objects each frame. + */ +export function resetScreen(screen: Screen, width: number, height: number): void { + // Warn if dimensions are not valid integers + warn.ifNotInteger(width, 'resetScreen width') + warn.ifNotInteger(height, 'resetScreen height') + + // Ensure width and height are valid integers to prevent crashes + if (!Number.isInteger(width) || width < 0) { + width = Math.max(0, Math.floor(width) || 0) + } + + if (!Number.isInteger(height) || height < 0) { + height = Math.max(0, Math.floor(height) || 0) + } + + const size = width * height + + // Resize if needed (only grow, to avoid reallocations) + if (screen.cells64.length < size) { + const buf = new ArrayBuffer(size << 3) + screen.cells = new Int32Array(buf) + screen.cells64 = new BigInt64Array(buf) + screen.noSelect = new Uint8Array(size) + } + + if (screen.softWrap.length < height) { + screen.softWrap = new Int32Array(height) + } + + // Reset all cells — single fill call, no loop + screen.cells64.fill(EMPTY_CELL_VALUE, 0, size) + screen.noSelect.fill(0, 0, size) + screen.softWrap.fill(0, 0, height) + + // Update dimensions + screen.width = width + screen.height = height + + // Shared pools accumulate — no clearing needed. Unique char/hyperlink sets are bounded. + + // Clear damage tracking + screen.damage = undefined +} + +/** + * Re-intern a screen's char and hyperlink IDs into new pools. + * Used for generational pool reset — after migrating, the screen's + * typed arrays contain valid IDs for the new pools, and the old pools + * can be GC'd. + * + * O(width * height) but only called occasionally (e.g., between conversation turns). + */ +export function migrateScreenPools(screen: Screen, charPool: CharPool, hyperlinkPool: HyperlinkPool): void { + const oldCharPool = screen.charPool + const oldHyperlinkPool = screen.hyperlinkPool + + if (oldCharPool === charPool && oldHyperlinkPool === hyperlinkPool) { + return + } + + const size = screen.width * screen.height + const cells = screen.cells + + // Re-intern chars and hyperlinks in a single pass, stride by 2 + for (let ci = 0; ci < size << 1; ci += 2) { + // Re-intern charId (word0) + const oldCharId = cells[ci]! + cells[ci] = charPool.intern(oldCharPool.get(oldCharId)) + + // Re-intern hyperlinkId (packed in word1) + const word1 = cells[ci + 1]! + const oldHyperlinkId = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK + + if (oldHyperlinkId !== 0) { + const oldStr = oldHyperlinkPool.get(oldHyperlinkId) + const newHyperlinkId = hyperlinkPool.intern(oldStr) + // Repack word1 with new hyperlinkId, preserving styleId and width + const styleId = word1 >>> STYLE_SHIFT + const width = word1 & WIDTH_MASK + cells[ci + 1] = packWord1(styleId, newHyperlinkId, width) + } + } + + screen.charPool = charPool + screen.hyperlinkPool = hyperlinkPool +} + +/** + * Get a Cell view at the given position. Returns a new object each call - + * this is intentional as cells are stored packed, not as objects. + */ +export function cellAt(screen: Screen, x: number, y: number): Cell | undefined { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) { + return undefined + } + + return cellAtIndex(screen, y * screen.width + x) +} + +/** + * Get a Cell view by pre-computed array index. Skips bounds checks and + * index computation — caller must ensure index is valid. + */ +export function cellAtIndex(screen: Screen, index: number): Cell { + const ci = index << 1 + const word1 = screen.cells[ci + 1]! + const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK + + return { + // Unwritten cells have charIndex=0 (EMPTY_CHAR_INDEX); charPool.get(0) returns ' ' + char: screen.charPool.get(screen.cells[ci]!), + styleId: word1 >>> STYLE_SHIFT, + width: word1 & WIDTH_MASK, + hyperlink: hid === 0 ? undefined : screen.hyperlinkPool.get(hid) + } +} + +/** + * Get a Cell at the given index, or undefined if it has no visible content. + * Returns undefined for spacer cells (charId 1), empty unstyled spaces, and + * fg-only styled spaces that match lastRenderedStyleId (cursor-forward + * produces an identical visual result, avoiding a Cell allocation). + * + * @param lastRenderedStyleId - styleId of the last rendered cell on this + * line, or -1 if none yet. + */ +export function visibleCellAtIndex( + cells: Int32Array, + charPool: CharPool, + hyperlinkPool: HyperlinkPool, + index: number, + lastRenderedStyleId: number +): Cell | undefined { + const ci = index << 1 + const charId = cells[ci]! + + if (charId === 1) { + return undefined + } // spacer + + const word1 = cells[ci + 1]! + + // For spaces: 0x3fffc masks bits 2-17 (hyperlinkId + styleId visibility + // bit). If zero, the space has no hyperlink and at most a fg-only style. + // Then word1 >>> STYLE_SHIFT is the foreground style — skip if it's zero + // (truly invisible) or matches the last rendered style on this line. + if (charId === 0 && (word1 & 0x3fffc) === 0) { + const fgStyle = word1 >>> STYLE_SHIFT + + if (fgStyle === 0 || fgStyle === lastRenderedStyleId) { + return undefined + } + } + + const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK + + return { + char: charPool.get(charId), + styleId: word1 >>> STYLE_SHIFT, + width: word1 & WIDTH_MASK, + hyperlink: hid === 0 ? undefined : hyperlinkPool.get(hid) + } +} + +/** + * Write cell data into an existing Cell object to avoid allocation. + * Caller must ensure index is valid. + */ +function cellAtCI(screen: Screen, ci: number, out: Cell): void { + const w1 = ci | 1 + const word1 = screen.cells[w1]! + out.char = screen.charPool.get(screen.cells[ci]!) + out.styleId = word1 >>> STYLE_SHIFT + out.width = word1 & WIDTH_MASK + const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK + out.hyperlink = hid === 0 ? undefined : screen.hyperlinkPool.get(hid) +} + +export function charInCellAt(screen: Screen, x: number, y: number): string | undefined { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) { + return undefined + } + + const ci = (y * screen.width + x) << 1 + + return screen.charPool.get(screen.cells[ci]!) +} + +/** + * Set a cell, optionally creating a spacer for wide characters. + * + * Wide characters (CJK, emoji) occupy 2 cells in the buffer: + * 1. First cell: Contains the actual character with width = Wide + * 2. Second cell: Spacer cell with width = SpacerTail (empty, not rendered) + * + * If the cell has width = Wide, this function automatically creates the + * corresponding SpacerTail in the next column. This two-cell model keeps + * the buffer aligned to visual columns, making cursor positioning + * straightforward. + * + * TODO: When soft-wrapping is implemented, SpacerHead cells will be explicitly + * placed by the wrapping logic at line-end positions where wide characters + * wrap to the next line. This function doesn't need to handle SpacerHead + * automatically - it will be set directly by the wrapping code. + */ +export function setCellAt(screen: Screen, x: number, y: number, cell: Cell): void { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) { + return + } + + const ci = (y * screen.width + x) << 1 + const cells = screen.cells + + // When a Wide char is overwritten by a Narrow char, its SpacerTail remains + // as a ghost cell that the diff/render pipeline skips, causing stale content + // to leak through from previous frames. + const prevWidth = cells[ci + 1]! & WIDTH_MASK + + if (prevWidth === CellWidth.Wide && cell.width !== CellWidth.Wide) { + const spacerX = x + 1 + + if (spacerX < screen.width) { + const spacerCI = ci + 2 + + if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { + cells[spacerCI] = EMPTY_CHAR_INDEX + cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + } + } + } + + // Track cleared Wide position for damage expansion below + let clearedWideX = -1 + + if (prevWidth === CellWidth.SpacerTail && cell.width !== CellWidth.SpacerTail) { + // Overwriting a SpacerTail: clear the orphaned Wide char at (x-1). + // Keeping the wide character with Narrow width would cause the terminal + // to still render it with width 2, desyncing the cursor model. + if (x > 0) { + const wideCI = ci - 2 + + if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { + cells[wideCI] = EMPTY_CHAR_INDEX + cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + clearedWideX = x - 1 + } + } + } + + // Pack cell data into cells array + cells[ci] = internCharString(screen, cell.char) + cells[ci + 1] = packWord1(cell.styleId, internHyperlink(screen, cell.hyperlink), cell.width) + + // Track damage - expand bounds in place instead of allocating new objects + // Include the main cell position and any cleared orphan cells + const minX = clearedWideX >= 0 ? Math.min(x, clearedWideX) : x + const damage = screen.damage + + if (damage) { + const right = damage.x + damage.width + const bottom = damage.y + damage.height + + if (minX < damage.x) { + damage.width += damage.x - minX + damage.x = minX + } else if (x >= right) { + damage.width = x - damage.x + 1 + } + + if (y < damage.y) { + damage.height += damage.y - y + damage.y = y + } else if (y >= bottom) { + damage.height = y - damage.y + 1 + } + } else { + screen.damage = { x: minX, y, width: x - minX + 1, height: 1 } + } + + // If this is a wide character, create a spacer in the next column + if (cell.width === CellWidth.Wide) { + const spacerX = x + 1 + + if (spacerX < screen.width) { + const spacerCI = ci + 2 + + // If the cell we're overwriting with our SpacerTail is itself Wide, + // clear ITS SpacerTail at x+2 too. Otherwise the orphan SpacerTail + // makes diffEach report it as `added` and log-update's skip-spacer + // rule prevents clearing whatever prev content was at that column. + // Scenario: [a, 💻, spacer] → [本, spacer, ORPHAN spacer] when + // yoga squishes a💻 to height 0 and 本 renders at the same y. + if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { + const orphanCI = spacerCI + 2 + + if (spacerX + 1 < screen.width && (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { + cells[orphanCI] = EMPTY_CHAR_INDEX + cells[orphanCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + } + } + + cells[spacerCI] = SPACER_CHAR_INDEX + cells[spacerCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.SpacerTail) + + // Expand damage to include SpacerTail so diff() scans it + const d = screen.damage + + if (d && spacerX >= d.x + d.width) { + d.width = spacerX - d.x + 1 + } + } + } +} + +/** + * Replace the styleId of a cell in-place without disturbing char, width, + * or hyperlink. Preserves empty cells as-is (char stays ' '). Tracks damage + * for the cell so diffEach picks up the change. + */ +export function setCellStyleId(screen: Screen, x: number, y: number, styleId: number): void { + if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) { + return + } + + const ci = (y * screen.width + x) << 1 + const cells = screen.cells + const word1 = cells[ci + 1]! + const width = word1 & WIDTH_MASK + + // Skip spacer cells — inverse on the head cell visually covers both columns + if (width === CellWidth.SpacerTail || width === CellWidth.SpacerHead) { + return + } + + const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK + cells[ci + 1] = packWord1(styleId, hid, width) + // Expand damage so diffEach scans this cell + const d = screen.damage + + if (d) { + screen.damage = unionRect(d, { x, y, width: 1, height: 1 }) + } else { + screen.damage = { x, y, width: 1, height: 1 } + } +} + +/** + * Intern a character string via the screen's shared CharPool. + * Supports grapheme clusters like family emoji. + */ +function internCharString(screen: Screen, char: string): number { + return screen.charPool.intern(char) +} + +/** + * Bulk-copy a rectangular region from src to dst using TypedArray.set(). + * Single cells.set() call per row (or one call for contiguous blocks). + * Damage is computed once for the whole region. + * + * Clamps negative regionX/regionY to 0 (matching clearRegion) — absolute- + * positioned overlays in tiny terminals can compute negative screen coords. + * maxX/maxY should already be clamped to both screen bounds by the caller. + */ +export function blitRegion( + dst: Screen, + src: Screen, + regionX: number, + regionY: number, + maxX: number, + maxY: number +): void { + regionX = Math.max(0, regionX) + regionY = Math.max(0, regionY) + + if (regionX >= maxX || regionY >= maxY) { + return + } + + const rowLen = maxX - regionX + const srcStride = src.width << 1 + const dstStride = dst.width << 1 + const rowBytes = rowLen << 1 // 2 Int32s per cell + const srcCells = src.cells + const dstCells = dst.cells + const srcNoSel = src.noSelect + const dstNoSel = dst.noSelect + + // softWrap is per-row — copy the row range regardless of stride/width. + // Partial-width blits still carry the row's wrap provenance since the + // blitted content (a cached ink-text node) is what set the bit. + dst.softWrap.set(src.softWrap.subarray(regionY, maxY), regionY) + + // Fast path: contiguous memory when copying full-width rows at same stride + if (regionX === 0 && maxX === src.width && src.width === dst.width) { + const srcStart = regionY * srcStride + const totalBytes = (maxY - regionY) * srcStride + dstCells.set( + srcCells.subarray(srcStart, srcStart + totalBytes), + srcStart // srcStart === dstStart when strides match and regionX === 0 + ) + // noSelect is 1 byte/cell vs cells' 8 — same region, different scale + const nsStart = regionY * src.width + const nsLen = (maxY - regionY) * src.width + dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart) + } else { + // Per-row copy for partial-width or mismatched-stride regions + let srcRowCI = regionY * srcStride + (regionX << 1) + let dstRowCI = regionY * dstStride + (regionX << 1) + let srcRowNS = regionY * src.width + regionX + let dstRowNS = regionY * dst.width + regionX + + for (let y = regionY; y < maxY; y++) { + dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI) + dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS) + srcRowCI += srcStride + dstRowCI += dstStride + srcRowNS += src.width + dstRowNS += dst.width + } + } + + // Compute damage once for the whole region + const regionRect = { + x: regionX, + y: regionY, + width: rowLen, + height: maxY - regionY + } + + if (dst.damage) { + dst.damage = unionRect(dst.damage, regionRect) + } else { + dst.damage = regionRect + } + + // Handle wide char at right edge: spacer might be outside blit region + // but still within dst bounds. Per-row check only at the boundary column. + if (maxX < dst.width) { + let srcLastCI = (regionY * src.width + (maxX - 1)) << 1 + let dstSpacerCI = (regionY * dst.width + maxX) << 1 + let wroteSpacerOutsideRegion = false + + for (let y = regionY; y < maxY; y++) { + if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) { + dstCells[dstSpacerCI] = SPACER_CHAR_INDEX + dstCells[dstSpacerCI + 1] = packWord1(dst.emptyStyleId, 0, CellWidth.SpacerTail) + wroteSpacerOutsideRegion = true + } + + srcLastCI += srcStride + dstSpacerCI += dstStride + } + + // Expand damage to include SpacerTail column if we wrote any + if (wroteSpacerOutsideRegion && dst.damage) { + const rightEdge = dst.damage.x + dst.damage.width + + if (rightEdge === maxX) { + dst.damage = { ...dst.damage, width: dst.damage.width + 1 } + } + } + } +} + +/** + * Bulk-clear a rectangular region of the screen. + * Uses BigInt64Array.fill() for fast row clears. + * Handles wide character boundary cleanup at region edges. + */ +export function clearRegion( + screen: Screen, + regionX: number, + regionY: number, + regionWidth: number, + regionHeight: number +): void { + const startX = Math.max(0, regionX) + const startY = Math.max(0, regionY) + const maxX = Math.min(regionX + regionWidth, screen.width) + const maxY = Math.min(regionY + regionHeight, screen.height) + + if (startX >= maxX || startY >= maxY) { + return + } + + const cells = screen.cells + const cells64 = screen.cells64 + const screenWidth = screen.width + const rowBase = startY * screenWidth + let damageMinX = startX + let damageMaxX = maxX + + // EMPTY_CELL_VALUE (0n) matches the zero-initialized state: + // word0=EMPTY_CHAR_INDEX(0), word1=packWord1(0,0,0)=0 + if (startX === 0 && maxX === screenWidth) { + // Full-width: single fill, no boundary checks needed + cells64.fill(EMPTY_CELL_VALUE, rowBase, rowBase + (maxY - startY) * screenWidth) + } else { + // Partial-width: single loop handles boundary cleanup and fill per row. + const stride = screenWidth << 1 // 2 Int32s per cell + const rowLen = maxX - startX + const checkLeft = startX > 0 + const checkRight = maxX < screenWidth + let leftEdge = (rowBase + startX) << 1 + let rightEdge = (rowBase + maxX - 1) << 1 + let fillStart = rowBase + startX + + for (let y = startY; y < maxY; y++) { + // Left boundary: if cell at startX is a SpacerTail, the Wide char + // at startX-1 (outside the region) will be orphaned. Clear it. + if (checkLeft) { + // leftEdge points to word0 of cell at startX; +1 is its word1 + if ((cells[leftEdge + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) { + // word1 of cell at startX-1 is leftEdge-1; word0 is leftEdge-2 + const prevW1 = leftEdge - 1 + + if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) { + cells[prevW1 - 1] = EMPTY_CHAR_INDEX + cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + damageMinX = startX - 1 + } + } + } + + // Right boundary: if cell at maxX-1 is Wide, its SpacerTail at maxX + // (outside the region) will be orphaned. Clear it. + if (checkRight) { + // rightEdge points to word0 of cell at maxX-1; +1 is its word1 + if ((cells[rightEdge + 1]! & WIDTH_MASK) === CellWidth.Wide) { + // word1 of cell at maxX is rightEdge+3 (+2 to next word0, +1 to word1) + const nextW1 = rightEdge + 3 + + if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) { + cells[nextW1 - 1] = EMPTY_CHAR_INDEX + cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow) + damageMaxX = maxX + 1 + } + } + } + + cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen) + leftEdge += stride + rightEdge += stride + fillStart += screenWidth + } + } + + // Update damage once for the whole region + const regionRect = { + x: damageMinX, + y: startY, + width: damageMaxX - damageMinX, + height: maxY - startY + } + + if (screen.damage) { + screen.damage = unionRect(screen.damage, regionRect) + } else { + screen.damage = regionRect + } +} + +/** + * Shift full-width rows within [top, bottom] (inclusive, 0-indexed) by n. + * n > 0 shifts UP (simulating CSI n S); n < 0 shifts DOWN (CSI n T). + * Vacated rows are cleared. Does NOT update damage. Both cells and the + * noSelect bitmap are shifted so text-selection markers stay aligned when + * this is applied to next.screen during scroll fast path. + */ +export function shiftRows(screen: Screen, top: number, bottom: number, n: number): void { + if (n === 0 || top < 0 || bottom >= screen.height || top > bottom) { + return + } + + const w = screen.width + const cells64 = screen.cells64 + const noSel = screen.noSelect + const sw = screen.softWrap + const absN = Math.abs(n) + + if (absN > bottom - top) { + cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w) + noSel.fill(0, top * w, (bottom + 1) * w) + sw.fill(0, top, bottom + 1) + + return + } + + if (n > 0) { + // SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom + cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) + noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w) + sw.copyWithin(top, top + n, bottom + 1) + cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w) + noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w) + sw.fill(0, bottom - n + 1, bottom + 1) + } else { + // SD: row top..bottom+n → top-n..bottom; clear top..top-n-1 + cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) + noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w) + sw.copyWithin(top - n, top, bottom + n + 1) + cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w) + noSel.fill(0, top * w, (top - n) * w) + sw.fill(0, top, top - n) + } +} + +// Matches OSC 8 ; ; URI BEL +const OSC8_REGEX = new RegExp(`^${ESC}\\]8${SEP}${SEP}([^${BEL}]*)${BEL}$`) +// OSC8 prefix: ESC ] 8 ; — cheap check to skip regex for the vast majority of styles (SGR = ESC [) +export const OSC8_PREFIX = `${ESC}]8${SEP}` + +export function extractHyperlinkFromStyles(styles: AnsiCode[]): Hyperlink | null { + for (const style of styles) { + const code = style.code + + if (code.length < 5 || !code.startsWith(OSC8_PREFIX)) { + continue + } + + const match = code.match(OSC8_REGEX) + + if (match) { + return match[1] || null + } + } + + return null +} + +export function filterOutHyperlinkStyles(styles: AnsiCode[]): AnsiCode[] { + return styles.filter(style => !style.code.startsWith(OSC8_PREFIX) || !OSC8_REGEX.test(style.code)) +} + +// --- + +/** + * Returns an array of all changes between two screens. Used by tests. + * Production code should use diffEach() to avoid allocations. + */ +export function diff(prev: Screen, next: Screen): [point: Point, removed: Cell | undefined, added: Cell | undefined][] { + const output: [Point, Cell | undefined, Cell | undefined][] = [] + diffEach(prev, next, (x, y, removed, added) => { + // Copy cells since diffEach reuses the objects + output.push([{ x, y }, removed ? { ...removed } : undefined, added ? { ...added } : undefined]) + }) + + return output +} + +type DiffCallback = (x: number, y: number, removed: Cell | undefined, added: Cell | undefined) => boolean | void + +/** + * Like diff(), but calls a callback for each change instead of building an array. + * Reuses two Cell objects to avoid per-change allocations. The callback must not + * retain references to the Cell objects — their contents are overwritten each call. + * + * Returns true if the callback ever returned true (early exit signal). + */ +export function diffEach(prev: Screen, next: Screen, cb: DiffCallback): boolean { + const prevWidth = prev.width + const nextWidth = next.width + const prevHeight = prev.height + const nextHeight = next.height + + let region: Rectangle + + if (prevWidth === 0 && prevHeight === 0) { + region = { x: 0, y: 0, width: nextWidth, height: nextHeight } + } else if (next.damage) { + region = next.damage + + if (prev.damage) { + region = unionRect(region, prev.damage) + } + } else if (prev.damage) { + region = prev.damage + } else { + region = { x: 0, y: 0, width: 0, height: 0 } + } + + if (prevHeight > nextHeight) { + region = unionRect(region, { + x: 0, + y: nextHeight, + width: prevWidth, + height: prevHeight - nextHeight + }) + } + + if (prevWidth > nextWidth) { + region = unionRect(region, { + x: nextWidth, + y: 0, + width: prevWidth - nextWidth, + height: prevHeight + }) + } + + const maxHeight = Math.max(prevHeight, nextHeight) + const maxWidth = Math.max(prevWidth, nextWidth) + const endY = Math.min(region.y + region.height, maxHeight) + const endX = Math.min(region.x + region.width, maxWidth) + + if (prevWidth === nextWidth) { + return diffSameWidth(prev, next, region.x, endX, region.y, endY, cb) + } + + return diffDifferentWidth(prev, next, region.x, endX, region.y, endY, cb) +} + +/** + * Scan for the next cell that differs between two Int32Arrays. + * Returns the number of matching cells before the first difference, + * or `count` if all cells match. Tiny and pure for JIT inlining. + */ +function findNextDiff(a: Int32Array, b: Int32Array, w0: number, count: number): number { + for (let i = 0; i < count; i++, w0 += 2) { + const w1 = w0 | 1 + + if (a[w0] !== b[w0] || a[w1] !== b[w1]) { + return i + } + } + + return count +} + +/** + * Diff one row where both screens are in bounds. + * Scans for differences with findNextDiff, unpacks and calls cb for each. + */ +function diffRowBoth( + prevCells: Int32Array, + nextCells: Int32Array, + prev: Screen, + next: Screen, + ci: number, + y: number, + startX: number, + endX: number, + prevCell: Cell, + nextCell: Cell, + cb: DiffCallback +): boolean { + let x = startX + + while (x < endX) { + const skip = findNextDiff(prevCells, nextCells, ci, endX - x) + x += skip + ci += skip << 1 + + if (x >= endX) { + break + } + + cellAtCI(prev, ci, prevCell) + cellAtCI(next, ci, nextCell) + + if (cb(x, y, prevCell, nextCell)) { + return true + } + + x++ + ci += 2 + } + + return false +} + +/** + * Emit removals for a row that only exists in prev (height shrank). + * Cannot skip empty cells — the terminal still has content from the + * previous frame that needs to be cleared. + */ +function diffRowRemoved( + prev: Screen, + ci: number, + y: number, + startX: number, + endX: number, + prevCell: Cell, + cb: DiffCallback +): boolean { + for (let x = startX; x < endX; x++, ci += 2) { + cellAtCI(prev, ci, prevCell) + + if (cb(x, y, prevCell, undefined)) { + return true + } + } + + return false +} + +/** + * Emit additions for a row that only exists in next (height grew). + * Skips empty/unwritten cells. + */ +function diffRowAdded( + nextCells: Int32Array, + next: Screen, + ci: number, + y: number, + startX: number, + endX: number, + nextCell: Cell, + cb: DiffCallback +): boolean { + for (let x = startX; x < endX; x++, ci += 2) { + if (nextCells[ci] === 0 && nextCells[ci | 1] === 0) { + continue + } + + cellAtCI(next, ci, nextCell) + + if (cb(x, y, undefined, nextCell)) { + return true + } + } + + return false +} + +/** + * Diff two screens with identical width. + * Dispatches each row to a small, JIT-friendly function. + */ +function diffSameWidth( + prev: Screen, + next: Screen, + startX: number, + endX: number, + startY: number, + endY: number, + cb: DiffCallback +): boolean { + const prevCells = prev.cells + const nextCells = next.cells + const width = prev.width + const prevHeight = prev.height + const nextHeight = next.height + const stride = width << 1 + + const prevCell: Cell = { + char: ' ', + styleId: 0, + width: CellWidth.Narrow, + hyperlink: undefined + } + + const nextCell: Cell = { + char: ' ', + styleId: 0, + width: CellWidth.Narrow, + hyperlink: undefined + } + + const rowEndX = Math.min(endX, width) + let rowCI = (startY * width + startX) << 1 + + for (let y = startY; y < endY; y++) { + const prevIn = y < prevHeight + const nextIn = y < nextHeight + + if (prevIn && nextIn) { + if (diffRowBoth(prevCells, nextCells, prev, next, rowCI, y, startX, rowEndX, prevCell, nextCell, cb)) { + return true + } + } else if (prevIn) { + if (diffRowRemoved(prev, rowCI, y, startX, rowEndX, prevCell, cb)) { + return true + } + } else if (nextIn) { + if (diffRowAdded(nextCells, next, rowCI, y, startX, rowEndX, nextCell, cb)) { + return true + } + } + + rowCI += stride + } + + return false +} + +/** + * Fallback: diff two screens with different widths (resize). + * Separate indices for prev and next cells arrays. + */ +function diffDifferentWidth( + prev: Screen, + next: Screen, + startX: number, + endX: number, + startY: number, + endY: number, + cb: DiffCallback +): boolean { + const prevWidth = prev.width + const nextWidth = next.width + const prevCells = prev.cells + const nextCells = next.cells + + const prevCell: Cell = { + char: ' ', + styleId: 0, + width: CellWidth.Narrow, + hyperlink: undefined + } + + const nextCell: Cell = { + char: ' ', + styleId: 0, + width: CellWidth.Narrow, + hyperlink: undefined + } + + const prevStride = prevWidth << 1 + const nextStride = nextWidth << 1 + let prevRowCI = (startY * prevWidth + startX) << 1 + let nextRowCI = (startY * nextWidth + startX) << 1 + + for (let y = startY; y < endY; y++) { + const prevIn = y < prev.height + const nextIn = y < next.height + const prevEndX = prevIn ? Math.min(endX, prevWidth) : startX + const nextEndX = nextIn ? Math.min(endX, nextWidth) : startX + const bothEndX = Math.min(prevEndX, nextEndX) + + let prevCI = prevRowCI + let nextCI = nextRowCI + + for (let x = startX; x < bothEndX; x++) { + if (prevCells[prevCI] === nextCells[nextCI] && prevCells[prevCI + 1] === nextCells[nextCI + 1]) { + prevCI += 2 + nextCI += 2 + + continue + } + + cellAtCI(prev, prevCI, prevCell) + cellAtCI(next, nextCI, nextCell) + prevCI += 2 + nextCI += 2 + + if (cb(x, y, prevCell, nextCell)) { + return true + } + } + + if (prevEndX > bothEndX) { + prevCI = prevRowCI + ((bothEndX - startX) << 1) + + for (let x = bothEndX; x < prevEndX; x++) { + cellAtCI(prev, prevCI, prevCell) + prevCI += 2 + + if (cb(x, y, prevCell, undefined)) { + return true + } + } + } + + if (nextEndX > bothEndX) { + nextCI = nextRowCI + ((bothEndX - startX) << 1) + + for (let x = bothEndX; x < nextEndX; x++) { + if (nextCells[nextCI] === 0 && nextCells[nextCI | 1] === 0) { + nextCI += 2 + + continue + } + + cellAtCI(next, nextCI, nextCell) + nextCI += 2 + + if (cb(x, y, undefined, nextCell)) { + return true + } + } + } + + prevRowCI += prevStride + nextRowCI += nextStride + } + + return false +} + +/** + * Mark a rectangular region as noSelect (exclude from text selection). + * Clamps to screen bounds. Called from output.ts when a box + * renders. No damage tracking — noSelect doesn't affect terminal output, + * only getSelectedText/applySelectionOverlay which read it directly. + */ +export function markNoSelectRegion(screen: Screen, x: number, y: number, width: number, height: number): void { + const maxX = Math.min(x + width, screen.width) + const maxY = Math.min(y + height, screen.height) + const noSel = screen.noSelect + const stride = screen.width + + for (let row = Math.max(0, y); row < maxY; row++) { + const rowStart = row * stride + noSel.fill(1, rowStart + Math.max(0, x), rowStart + maxX) + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/searchHighlight.ts b/ui-tui/packages/hermes-ink/src/ink/searchHighlight.ts new file mode 100644 index 0000000000..278c3fd63c --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/searchHighlight.ts @@ -0,0 +1,91 @@ +import { cellAtIndex, CellWidth, type Screen, setCellStyleId, type StylePool } from './screen.js' + +/** + * Highlight all visible occurrences of `query` in the screen buffer by + * inverting cell styles (SGR 7). Post-render, same damage-tracking machinery + * as applySelectionOverlay — the diff picks up highlighted cells as ordinary + * changes, LogUpdate stays a pure diff engine. + * + * Case-insensitive. Handles wide characters (CJK, emoji) by building a + * col-of-char map per row — the Nth character isn't at col N when wide chars + * are present (each occupies 2 cells: head + SpacerTail). + * + * This ONLY inverts — there is no "current match" logic here. The yellow + * current-match overlay is handled separately by applyPositionedHighlight + * (render-to-screen.ts), which writes on top using positions scanned from + * the target message's DOM subtree. + * + * Returns true if any match was highlighted (damage gate — caller forces + * full-frame damage when true). + */ +export function applySearchHighlight(screen: Screen, query: string, stylePool: StylePool): boolean { + if (!query) { + return false + } + + const lq = query.toLowerCase() + const qlen = lq.length + const w = screen.width + const noSelect = screen.noSelect + const height = screen.height + + let applied = false + + for (let row = 0; row < height; row++) { + const rowOff = row * w + // Build row text (already lowercased) + code-unit→cell-index map. + // Three skip conditions, all aligned with setCellStyleId / + // extractRowText (selection.ts): + // - SpacerTail: 2nd cell of a wide char, no char of its own + // - SpacerHead: end-of-line padding when a wide char wraps + // - noSelect: gutters (⎿, line numbers) — same exclusion as + // applySelectionOverlay. "Highlight what you see" still holds for + // content; gutters aren't search targets. + // Lowercasing per-char (not on the joined string at the end) means + // codeUnitToCell maps positions in the LOWERCASED text — U+0130 + // (Turkish İ) lowercases to 2 code units, so lowering the joined + // string would desync indexOf positions from the map. + let text = '' + const colOf: number[] = [] + const codeUnitToCell: number[] = [] + + for (let col = 0; col < w; col++) { + const idx = rowOff + col + const cell = cellAtIndex(screen, idx) + + if (cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead || noSelect[idx] === 1) { + continue + } + + const lc = cell.char.toLowerCase() + const cellIdx = colOf.length + + for (let i = 0; i < lc.length; i++) { + codeUnitToCell.push(cellIdx) + } + + text += lc + colOf.push(col) + } + + let pos = text.indexOf(lq) + + while (pos >= 0) { + applied = true + const startCi = codeUnitToCell[pos]! + const endCi = codeUnitToCell[pos + qlen - 1]! + + for (let ci = startCi; ci <= endCi; ci++) { + const col = colOf[ci]! + const cell = cellAtIndex(screen, rowOff + col) + setCellStyleId(screen, col, row, stylePool.withInverse(cell.styleId)) + } + + // Non-overlapping advance (less/vim/grep/Ctrl+F). pos+1 would find + // 'aa' at 0 AND 1 in 'aaa' → double-invert cell 1. + pos = text.indexOf(lq, pos + qlen) + } + } + + return applied +} diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts new file mode 100644 index 0000000000..ccd8e49574 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -0,0 +1,1071 @@ +/** + * Text selection state for fullscreen mode. + * + * Tracks a linear selection in screen-buffer coordinates (0-indexed col/row). + * Selection is line-based: cells from (startCol, startRow) through + * (endCol, endRow) inclusive, wrapping across line boundaries. This matches + * terminal-native selection behavior (not rectangular/block). + * + * The selection is stored as ANCHOR (where the drag started) + FOCUS (where + * the cursor is now). The rendered highlight normalizes to start ≤ end. + */ + +import { clamp } from './layout/geometry.js' +import type { Screen, StylePool } from './screen.js' +import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js' + +type Point = { col: number; row: number } + +export type SelectionState = { + /** Where the mouse-down occurred. Null when no selection. */ + anchor: Point | null + /** Current drag position (updated on mouse-move while dragging). */ + focus: Point | null + /** True between mouse-down and mouse-up. */ + isDragging: boolean + /** For word/line mode: the initial word/line bounds from the first + * multi-click. Drag extends from this span to the word/line at the + * current mouse position so the original word/line stays selected + * even when dragging backward past it. Null ⇔ char mode. The kind + * tells extendSelection whether to snap to word or line boundaries. */ + anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null + /** Text from rows that scrolled out ABOVE the viewport during + * drag-to-scroll. The screen buffer only holds the current viewport, + * so without this accumulator, dragging down past the bottom edge + * loses the top of the selection once the anchor clamps. Prepended + * to the on-screen text by getSelectedText. Reset on start/clear. */ + scrolledOffAbove: string[] + /** Symmetric: rows scrolled out BELOW when dragging up. Appended. */ + scrolledOffBelow: string[] + /** Soft-wrap bits parallel to scrolledOffAbove — true means the row + * is a continuation of the one before it (the `\n` was inserted by + * word-wrap, not in the source). Captured alongside the text at + * scroll time since the screen's softWrap bitmap shifts with content. + * getSelectedText uses these to join wrapped rows back into logical + * lines. */ + scrolledOffAboveSW: boolean[] + /** Parallel to scrolledOffBelow. */ + scrolledOffBelowSW: boolean[] + /** Pre-clamp anchor row. Set when shiftSelection clamps anchor so a + * reverse scroll can restore the true position and pop accumulators. + * Without this, PgDn (clamps anchor) → PgUp leaves anchor at the wrong + * row AND scrolledOffAbove stale — highlight ≠ copy. Undefined when + * anchor is in-bounds (no clamp debt). Cleared on start/clear. */ + virtualAnchorRow?: number + /** Same for focus. */ + virtualFocusRow?: number + /** True if the mouse-down that started this selection had the alt + * modifier set (SGR button bit 0x08). On macOS xterm.js this is a + * signal that VS Code's macOptionClickForcesSelection is OFF — if it + * were on, xterm.js would have consumed the event for native selection + * and we'd never receive it. Used by the footer to show the right hint. */ + lastPressHadAlt: boolean +} + +export function createSelectionState(): SelectionState { + return { + anchor: null, + focus: null, + isDragging: false, + anchorSpan: null, + scrolledOffAbove: [], + scrolledOffBelow: [], + scrolledOffAboveSW: [], + scrolledOffBelowSW: [], + lastPressHadAlt: false + } +} + +export function startSelection(s: SelectionState, col: number, row: number): void { + s.anchor = { col, row } + // Focus is not set until the first drag motion. A click-release with no + // drag leaves focus null → hasSelection/selectionBounds return false/null + // via the `!s.focus` check, so a bare click never highlights a cell. + s.focus = null + s.isDragging = true + s.anchorSpan = null + s.scrolledOffAbove = [] + s.scrolledOffBelow = [] + s.scrolledOffAboveSW = [] + s.scrolledOffBelowSW = [] + s.virtualAnchorRow = undefined + s.virtualFocusRow = undefined + s.lastPressHadAlt = false +} + +export function updateSelection(s: SelectionState, col: number, row: number): void { + if (!s.isDragging) { + return + } + + // First motion at the same cell as anchor is a no-op. Terminals in mode + // 1002 can fire a drag event at the anchor cell (sub-pixel tremor, or a + // motion-release pair). Setting focus here would turn a bare click into + // a 1-cell selection and clobber the clipboard via useCopyOnSelect. Once + // focus is set (real drag), we track normally including back to anchor. + if (!s.focus && s.anchor && s.anchor.col === col && s.anchor.row === row) { + return + } + + s.focus = { col, row } +} + +export function finishSelection(s: SelectionState): void { + s.isDragging = false + // Keep anchor/focus so highlight stays visible and text can be copied. + // Clear via clearSelection() on Esc or after copy. +} + +export function clearSelection(s: SelectionState): void { + s.anchor = null + s.focus = null + s.isDragging = false + s.anchorSpan = null + s.scrolledOffAbove = [] + s.scrolledOffBelow = [] + s.scrolledOffAboveSW = [] + s.scrolledOffBelowSW = [] + s.virtualAnchorRow = undefined + s.virtualFocusRow = undefined + s.lastPressHadAlt = false +} + +// Unicode-aware word character matcher: letters (any script), digits, +// and the punctuation set iTerm2 treats as word-part by default. +// Matching iTerm2's default means double-clicking a path like +// `/usr/bin/bash` or `~/.claude/config.json` selects the whole thing, +// which is the muscle memory most macOS terminal users have. +// iTerm2 default "characters considered part of a word": /-+\~_. +const WORD_CHAR = /[\p{L}\p{N}_/.\-+~\\]/u + +/** + * Character class for double-click word-expansion. Cells with the same + * class as the clicked cell are included in the selection; a class change + * is a boundary. Matches typical terminal-emulator behavior (iTerm2 etc.): + * double-click on `foo` selects `foo`, on `->` selects `->`, on spaces + * selects the whitespace run. + */ +function charClass(c: string): 0 | 1 | 2 { + if (c === ' ' || c === '') { + return 0 + } + + if (WORD_CHAR.test(c)) { + return 1 + } + + return 2 +} + +/** + * Find the bounds of the same-class character run at (col, row). Returns + * null if the click is out of bounds or lands on a noSelect cell. Used by + * selectWordAt (initial double-click) and extendWordSelection (drag). + */ +function wordBoundsAt(screen: Screen, col: number, row: number): { lo: number; hi: number } | null { + if (row < 0 || row >= screen.height) { + return null + } + + const width = screen.width + const noSelect = screen.noSelect + const rowOff = row * width + + // If the click landed on the spacer tail of a wide char, step back to + // the head so the class check sees the actual grapheme. + let c = col + + if (c > 0) { + const cell = cellAt(screen, c, row) + + if (cell && cell.width === CellWidth.SpacerTail) { + c -= 1 + } + } + + if (c < 0 || c >= width || noSelect[rowOff + c] === 1) { + return null + } + + const startCell = cellAt(screen, c, row) + + if (!startCell) { + return null + } + + const cls = charClass(startCell.char) + + // Expand left: include cells of the same class, stop at noSelect or + // class change. SpacerTail cells are stepped over (the wide-char head + // at the preceding column determines the class). + let lo = c + + while (lo > 0) { + const prev = lo - 1 + + if (noSelect[rowOff + prev] === 1) { + break + } + + const pc = cellAt(screen, prev, row) + + if (!pc) { + break + } + + if (pc.width === CellWidth.SpacerTail) { + // Step over the spacer to the wide-char head + if (prev === 0 || noSelect[rowOff + prev - 1] === 1) { + break + } + + const head = cellAt(screen, prev - 1, row) + + if (!head || charClass(head.char) !== cls) { + break + } + + lo = prev - 1 + + continue + } + + if (charClass(pc.char) !== cls) { + break + } + + lo = prev + } + + // Expand right: same logic, skipping spacer tails. + let hi = c + + while (hi < width - 1) { + const next = hi + 1 + + if (noSelect[rowOff + next] === 1) { + break + } + + const nc = cellAt(screen, next, row) + + if (!nc) { + break + } + + if (nc.width === CellWidth.SpacerTail) { + // Include the spacer tail in the selection range (it belongs to + // the wide char at hi) and continue past it. + hi = next + + continue + } + + if (charClass(nc.char) !== cls) { + break + } + + hi = next + } + + return { lo, hi } +} + +/** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */ +function comparePoints(a: Point, b: Point): number { + if (a.row !== b.row) { + return a.row < b.row ? -1 : 1 + } + + if (a.col !== b.col) { + return a.col < b.col ? -1 : 1 + } + + return 0 +} + +/** + * Select the word at (col, row) by scanning the screen buffer for the + * bounds of the same-class character run. Mutates the selection in place. + * No-op if the click is out of bounds or lands on a noSelect cell. + * Sets isDragging=true and anchorSpan so a subsequent drag extends the + * selection word-by-word (native macOS behavior). + */ +export function selectWordAt(s: SelectionState, screen: Screen, col: number, row: number): void { + const b = wordBoundsAt(screen, col, row) + + if (!b) { + return + } + + const lo = { col: b.lo, row } + const hi = { col: b.hi, row } + s.anchor = lo + s.focus = hi + s.isDragging = true + s.anchorSpan = { lo, hi, kind: 'word' } +} + +// Printable ASCII minus terminal URL delimiters. Restricting to single- +// codeunit ASCII keeps cell-count === string-index, so the column-span +// check below is exact (no wide-char/grapheme drift). +const URL_BOUNDARY = new Set([...'<>"\'` ']) + +function isUrlChar(c: string): boolean { + if (c.length !== 1) { + return false + } + + const code = c.charCodeAt(0) + + return code >= 0x21 && code <= 0x7e && !URL_BOUNDARY.has(c) +} + +/** + * Scan the screen buffer for a plain-text URL at (col, row). Mirrors the + * terminal's native Cmd+Click URL detection, which fullscreen mode's mouse + * tracking intercepts. Called from getHyperlinkAt as a fallback when the + * cell has no OSC 8 hyperlink. + */ +export function findPlainTextUrlAt(screen: Screen, col: number, row: number): string | undefined { + if (row < 0 || row >= screen.height) { + return undefined + } + + const width = screen.width + const noSelect = screen.noSelect + const rowOff = row * width + + let c = col + + if (c > 0) { + const cell = cellAt(screen, c, row) + + if (cell && cell.width === CellWidth.SpacerTail) { + c -= 1 + } + } + + if (c < 0 || c >= width || noSelect[rowOff + c] === 1) { + return undefined + } + + const startCell = cellAt(screen, c, row) + + if (!startCell || !isUrlChar(startCell.char)) { + return undefined + } + + // Expand left/right to the bounds of the URL-char run. URLs are ASCII + // (CellWidth.Narrow, 1 codeunit), so hitting a non-ASCII/wide/spacer + // cell is a boundary — no need to step over spacers like wordBoundsAt. + let lo = c + + while (lo > 0) { + const prev = lo - 1 + + if (noSelect[rowOff + prev] === 1) { + break + } + + const pc = cellAt(screen, prev, row) + + if (!pc || pc.width !== CellWidth.Narrow || !isUrlChar(pc.char)) { + break + } + + lo = prev + } + + let hi = c + + while (hi < width - 1) { + const next = hi + 1 + + if (noSelect[rowOff + next] === 1) { + break + } + + const nc = cellAt(screen, next, row) + + if (!nc || nc.width !== CellWidth.Narrow || !isUrlChar(nc.char)) { + break + } + + hi = next + } + + let token = '' + + for (let i = lo; i <= hi; i++) { + token += cellAt(screen, i, row)!.char + } + + // 1 cell = 1 char across [lo, hi] (ASCII-only run), so string index = + // column offset. Find the last scheme anchor at or before the click — + // a run like `https://a.com,https://b.com` has two, and clicking the + // second should return the second URL, not the greedy match of both. + const clickIdx = c - lo + const schemeRe = /(?:https?|file):\/\//g + let urlStart = -1 + let urlEnd = token.length + + for (let m; (m = schemeRe.exec(token)); ) { + if (m.index > clickIdx) { + urlEnd = m.index + + break + } + + urlStart = m.index + } + + if (urlStart < 0) { + return undefined + } + + let url = token.slice(urlStart, urlEnd) + + // Strip trailing sentence punctuation. For closers () ] }, only strip + // if unbalanced — `/wiki/Foo_(bar)` keeps `)`, `/arr[0]` keeps `]`. + const OPENER: Record = { ')': '(', ']': '[', '}': '{' } + + while (url.length > 0) { + const last = url.at(-1)! + + if ('.,;:!?'.includes(last)) { + url = url.slice(0, -1) + + continue + } + + const opener = OPENER[last] + + if (!opener) { + break + } + + let opens = 0 + let closes = 0 + + for (let i = 0; i < url.length; i++) { + const ch = url.charAt(i) + + if (ch === opener) { + opens++ + } else if (ch === last) { + closes++ + } + } + + if (closes > opens) { + url = url.slice(0, -1) + } else { + break + } + } + + // urlStart already guarantees click >= URL start; check right edge. + if (clickIdx >= urlStart + url.length) { + return undefined + } + + return url +} + +/** + * Select the entire row. Sets isDragging=true and anchorSpan so a + * subsequent drag extends the selection line-by-line. The anchor/focus + * span from col 0 to width-1; getSelectedText handles noSelect skipping + * and trailing-whitespace trimming so the copied text is just the visible + * line content. + */ +export function selectLineAt(s: SelectionState, screen: Screen, row: number): void { + if (row < 0 || row >= screen.height) { + return + } + + const lo = { col: 0, row } + const hi = { col: screen.width - 1, row } + s.anchor = lo + s.focus = hi + s.isDragging = true + s.anchorSpan = { lo, hi, kind: 'line' } +} + +/** + * Extend a word/line-mode selection to the word/line at (col, row). The + * anchor span (the original multi-clicked word/line) stays selected; the + * selection grows from that span to the word/line at the current mouse + * position. Word mode falls back to the raw cell when the mouse is over a + * noSelect cell or out of bounds, so dragging into gutters still extends. + */ +export function extendSelection(s: SelectionState, screen: Screen, col: number, row: number): void { + if (!s.isDragging || !s.anchorSpan) { + return + } + + const span = s.anchorSpan + let mLo: Point + let mHi: Point + + if (span.kind === 'word') { + const b = wordBoundsAt(screen, col, row) + mLo = { col: b ? b.lo : col, row } + mHi = { col: b ? b.hi : col, row } + } else { + const r = clamp(row, 0, screen.height - 1) + mLo = { col: 0, row: r } + mHi = { col: screen.width - 1, row: r } + } + + if (comparePoints(mHi, span.lo) < 0) { + // Mouse target ends before anchor span: extend backward. + s.anchor = span.hi + s.focus = mLo + } else if (comparePoints(mLo, span.hi) > 0) { + // Mouse target starts after anchor span: extend forward. + s.anchor = span.lo + s.focus = mHi + } else { + // Mouse overlaps the anchor span: just select the anchor span. + s.anchor = span.lo + s.focus = span.hi + } +} + +/** Semantic keyboard focus moves. See moveSelectionFocus in ink.tsx for + * how screen bounds + row-wrap are applied. */ +export type FocusMove = 'left' | 'right' | 'up' | 'down' | 'lineStart' | 'lineEnd' + +/** + * Set focus to (col, row) for keyboard selection extension (shift+arrow). + * Anchor stays fixed; selection grows or shrinks depending on where focus + * moves relative to anchor. Drops to char mode (clears anchorSpan) — + * native macOS does this too: shift+arrow after a double-click word-select + * extends char-by-char from the word edge, not word-by-word. Scrolled-off + * accumulators are preserved: keyboard-extending a drag-scrolled selection + * keeps the off-screen rows. Caller supplies coords already clamped/wrapped. + */ +export function moveFocus(s: SelectionState, col: number, row: number): void { + if (!s.focus) { + return + } + + s.anchorSpan = null + s.focus = { col, row } + // Explicit user repositioning — any stale virtual focus (from a prior + // shiftSelection clamp) no longer reflects intent. Anchor stays put so + // virtualAnchorRow is still valid for its own round-trip. + s.virtualFocusRow = undefined +} + +/** + * Shift anchor AND focus by dRow, clamped to [minRow, maxRow]. Used for + * keyboard scroll (PgUp/PgDn/ctrl+u/d/b/f): the whole selection must track + * the content, unlike drag-to-scroll where focus stays at the mouse. Any + * point that hits a clamp bound gets its col reset to the full-width edge — + * its original content scrolled off-screen and was captured by + * captureScrolledRows, so the col constraint was already consumed. Keeping + * it would truncate the NEW content now at that screen row. Clamp col is 0 + * for dRow<0 (scrolling down, top leaves, 'above' semantics) or width-1 for + * dRow>0 (scrolling up, bottom leaves, 'below' semantics). + * + * If both ends overshoot the SAME viewport edge (select text → Home/End/g/G + * jumps far enough that both are out of view), clear — otherwise both clamp + * to the same corner cell and a ghost 1-cell highlight lingers, and + * getSelectedText returns one unrelated char from that corner. Symmetric + * with shiftSelectionForFollow's top-edge check, but bidirectional: keyboard + * scroll can jump either way. + */ +export function shiftSelection(s: SelectionState, dRow: number, minRow: number, maxRow: number, width: number): void { + if (!s.anchor || !s.focus) { + return + } + + // Virtual rows track pre-clamp positions so reverse scrolls restore + // correctly. Without this, clamp(5→0) + shift(+10) = 10, not the true 5, + // and scrolledOffAbove stays stale (highlight ≠ copy). + const vAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow + const vFocus = (s.virtualFocusRow ?? s.focus.row) + dRow + + if ((vAnchor < minRow && vFocus < minRow) || (vAnchor > maxRow && vFocus > maxRow)) { + clearSelection(s) + + return + } + + // Debt = how far the nearer endpoint overshoots each edge. When debt + // shrinks (reverse scroll), those rows are back on-screen — pop from + // the accumulator so getSelectedText doesn't double-count them. + const oldMin = Math.min(s.virtualAnchorRow ?? s.anchor.row, s.virtualFocusRow ?? s.focus.row) + + const oldMax = Math.max(s.virtualAnchorRow ?? s.anchor.row, s.virtualFocusRow ?? s.focus.row) + + const oldAboveDebt = Math.max(0, minRow - oldMin) + const oldBelowDebt = Math.max(0, oldMax - maxRow) + const newAboveDebt = Math.max(0, minRow - Math.min(vAnchor, vFocus)) + const newBelowDebt = Math.max(0, Math.max(vAnchor, vFocus) - maxRow) + + if (newAboveDebt < oldAboveDebt) { + // scrolledOffAbove pushes newest at the end (closest to on-screen). + const drop = oldAboveDebt - newAboveDebt + s.scrolledOffAbove.length -= drop + s.scrolledOffAboveSW.length = s.scrolledOffAbove.length + } + + if (newBelowDebt < oldBelowDebt) { + // scrolledOffBelow unshifts newest at the front (closest to on-screen). + const drop = oldBelowDebt - newBelowDebt + s.scrolledOffBelow.splice(0, drop) + s.scrolledOffBelowSW.splice(0, drop) + } + + // Invariant: accumulator length ≤ debt. If the accumulator exceeds debt, + // the excess is stale — e.g., moveFocus cleared virtualFocusRow without + // trimming the accumulator, orphaning entries the pop above can never + // reach because oldDebt was ALREADY 0. Truncate to debt (keeping the + // newest = closest-to-on-screen entries). Check newDebt (not oldDebt): + // captureScrolledRows runs BEFORE this shift in the real flow (ink.tsx), + // so at entry the accumulator is populated but oldDebt is still 0 — + // that's the normal establish-debt path, not stale. + if (s.scrolledOffAbove.length > newAboveDebt) { + // Above pushes newest at END → keep END. + s.scrolledOffAbove = newAboveDebt > 0 ? s.scrolledOffAbove.slice(-newAboveDebt) : [] + s.scrolledOffAboveSW = newAboveDebt > 0 ? s.scrolledOffAboveSW.slice(-newAboveDebt) : [] + } + + if (s.scrolledOffBelow.length > newBelowDebt) { + // Below unshifts newest at FRONT → keep FRONT. + s.scrolledOffBelow = s.scrolledOffBelow.slice(0, newBelowDebt) + s.scrolledOffBelowSW = s.scrolledOffBelowSW.slice(0, newBelowDebt) + } + + // Clamp col depends on which EDGE (not dRow direction): virtual tracking + // means a top-clamped point can stay top-clamped during a dRow>0 reverse + // shift — dRow-based clampCol would give it the bottom col. + const shift = (p: Point, vRow: number): Point => { + if (vRow < minRow) { + return { col: 0, row: minRow } + } + + if (vRow > maxRow) { + return { col: width - 1, row: maxRow } + } + + return { col: p.col, row: vRow } + } + + s.anchor = shift(s.anchor, vAnchor) + s.focus = shift(s.focus, vFocus) + s.virtualAnchorRow = vAnchor < minRow || vAnchor > maxRow ? vAnchor : undefined + s.virtualFocusRow = vFocus < minRow || vFocus > maxRow ? vFocus : undefined + + // anchorSpan not virtual-tracked: it's for word/line extend-on-drag, + // irrelevant to the keyboard-scroll round-trip case. + if (s.anchorSpan) { + const sp = (p: Point): Point => { + const r = p.row + dRow + + if (r < minRow) { + return { col: 0, row: minRow } + } + + if (r > maxRow) { + return { col: width - 1, row: maxRow } + } + + return { col: p.col, row: r } + } + + s.anchorSpan = { + lo: sp(s.anchorSpan.lo), + hi: sp(s.anchorSpan.hi), + kind: s.anchorSpan.kind + } + } +} + +/** + * Shift the anchor row by dRow, clamped to [minRow, maxRow]. Used during + * drag-to-scroll: when the ScrollBox scrolls by N rows, the content that + * was under the anchor is now at a different viewport row, so the anchor + * must follow it. Focus is left unchanged (it stays at the mouse position). + */ +export function shiftAnchor(s: SelectionState, dRow: number, minRow: number, maxRow: number): void { + if (!s.anchor) { + return + } + + // Same virtual-row tracking as shiftSelection/shiftSelectionForFollow: the + // drag→follow transition hands off to shiftSelectionForFollow, which reads + // (virtualAnchorRow ?? anchor.row). Without this, drag-phase clamping + // leaves virtual undefined → follow initializes from the already-clamped + // row, under-counting total drift → shiftSelection's invariant-restore + // prematurely clears valid drag-phase accumulator entries. + const raw = (s.virtualAnchorRow ?? s.anchor.row) + dRow + s.anchor = { col: s.anchor.col, row: clamp(raw, minRow, maxRow) } + s.virtualAnchorRow = raw < minRow || raw > maxRow ? raw : undefined + + // anchorSpan not virtual-tracked (word/line extend, irrelevant to + // keyboard-scroll round-trip) — plain clamp from current row. + if (s.anchorSpan) { + const shift = (p: Point): Point => ({ + col: p.col, + row: clamp(p.row + dRow, minRow, maxRow) + }) + + s.anchorSpan = { + lo: shift(s.anchorSpan.lo), + hi: shift(s.anchorSpan.hi), + kind: s.anchorSpan.kind + } + } +} + +/** + * Shift the whole selection (anchor + focus + anchorSpan) by dRow, clamped + * to [minRow, maxRow]. Used when sticky/auto-follow scrolls the ScrollBox + * while a selection is active — native terminal behavior is for the + * highlight to walk up the screen with the text (not stay at the same + * screen position). + * + * Differs from shiftAnchor: during drag-to-scroll, focus tracks the live + * mouse position and only anchor follows the text. During streaming-follow, + * the selection is text-anchored at both ends — both must move. The + * isDragging check in ink.tsx picks which shift to apply. + * + * If both ends would shift strictly BELOW minRow (unclamped), the selected + * text has scrolled entirely off the top. Clear it — otherwise a single + * inverted cell lingers at the viewport top as a ghost (native terminals + * drop the selection when it leaves scrollback). Landing AT minRow is + * still valid: that cell holds the correct text. Returns true if the + * selection was cleared so the caller can notify React-land subscribers + * (useHasSelection) — the caller is inside onRender so it can't use + * notifySelectionChange (recursion), must fire listeners directly. + */ +export function shiftSelectionForFollow(s: SelectionState, dRow: number, minRow: number, maxRow: number): boolean { + if (!s.anchor) { + return false + } + + // Mirror shiftSelection: compute raw (unclamped) positions from virtual + // if set, else current. This handles BOTH the update path (virtual already + // set from a prior keyboard scroll) AND the initialize path (first clamp + // happens HERE via follow-scroll, no prior keyboard scroll). Without the + // initialize path, follow-scroll-first leaves virtual undefined even + // though the clamp below occurred → a later PgUp computes debt from the + // clamped row instead of the true pre-clamp row and never pops the + // accumulator — getSelectedText double-counts the off-screen rows. + const rawAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow + + const rawFocus = s.focus ? (s.virtualFocusRow ?? s.focus.row) + dRow : undefined + + if (rawAnchor < minRow && rawFocus !== undefined && rawFocus < minRow) { + clearSelection(s) + + return true + } + + // Clamp from raw, not p.row+dRow — so a virtual position coming back + // in-bounds lands at the TRUE position, not the stale clamped one. + s.anchor = { col: s.anchor.col, row: clamp(rawAnchor, minRow, maxRow) } + + if (s.focus && rawFocus !== undefined) { + s.focus = { col: s.focus.col, row: clamp(rawFocus, minRow, maxRow) } + } + + s.virtualAnchorRow = rawAnchor < minRow || rawAnchor > maxRow ? rawAnchor : undefined + s.virtualFocusRow = rawFocus !== undefined && (rawFocus < minRow || rawFocus > maxRow) ? rawFocus : undefined + + // anchorSpan not virtual-tracked (word/line extend, irrelevant to + // keyboard-scroll round-trip) — plain clamp from current row. + if (s.anchorSpan) { + const shift = (p: Point): Point => ({ + col: p.col, + row: clamp(p.row + dRow, minRow, maxRow) + }) + + s.anchorSpan = { + lo: shift(s.anchorSpan.lo), + hi: shift(s.anchorSpan.hi), + kind: s.anchorSpan.kind + } + } + + return false +} + +export function hasSelection(s: SelectionState): boolean { + return s.anchor !== null && s.focus !== null +} + +/** + * Normalized selection bounds: start is always before end in reading order. + * Returns null if no active selection. + */ +export function selectionBounds(s: SelectionState): { + start: { col: number; row: number } + end: { col: number; row: number } +} | null { + if (!s.anchor || !s.focus) { + return null + } + + return comparePoints(s.anchor, s.focus) <= 0 ? { start: s.anchor, end: s.focus } : { start: s.focus, end: s.anchor } +} + +/** + * Check if a cell at (col, row) is within the current selection range. + * Used by the renderer to apply inverse style. + */ +export function isCellSelected(s: SelectionState, col: number, row: number): boolean { + const b = selectionBounds(s) + + if (!b) { + return false + } + + const { start, end } = b + + if (row < start.row || row > end.row) { + return false + } + + if (row === start.row && col < start.col) { + return false + } + + if (row === end.row && col > end.col) { + return false + } + + return true +} + +/** Extract text from one screen row. When the next row is a soft-wrap + * continuation (screen.softWrap[row+1]>0), clamp to that content-end + * column and skip the trailing trim so the word-separator space survives + * the join. See Screen.softWrap for why the clamp is necessary. */ +function extractRowText(screen: Screen, row: number, colStart: number, colEnd: number): string { + const noSelect = screen.noSelect + const rowOff = row * screen.width + const contentEnd = row + 1 < screen.height ? screen.softWrap[row + 1]! : 0 + const lastCol = contentEnd > 0 ? Math.min(colEnd, contentEnd - 1) : colEnd + let line = '' + + for (let col = colStart; col <= lastCol; col++) { + // Skip cells marked noSelect (gutters, line numbers, diff sigils). + // Check before cellAt to avoid the decode cost for excluded cells. + if (noSelect[rowOff + col] === 1) { + continue + } + + const cell = cellAt(screen, col, row) + + if (!cell) { + continue + } + + // Skip spacer tails (second half of wide chars) — the head already + // contains the full grapheme. SpacerHead is a blank at line-end. + if (cell.width === CellWidth.SpacerTail || cell.width === CellWidth.SpacerHead) { + continue + } + + line += cell.char + } + + return contentEnd > 0 ? line : line.replace(/\s+$/, '') +} + +/** Accumulator for selected text that merges soft-wrapped rows back + * into logical lines. push(text, sw) appends a newline before text + * only when sw=false (i.e. the row starts a new logical line). Rows + * with sw=true are concatenated onto the previous row. */ +function joinRows(lines: string[], text: string, sw: boolean | undefined): void { + if (sw && lines.length > 0) { + lines[lines.length - 1] += text + } else { + lines.push(text) + } +} + +/** + * Extract text from the screen buffer within the selection range. + * Rows are joined with newlines unless the screen's softWrap bitmap + * marks a row as a word-wrap continuation — those rows are concatenated + * onto the previous row so the copied text matches the logical source + * line, not the visual wrapped layout. Trailing whitespace on the last + * fragment of each logical line is trimmed. Wide-char spacer cells are + * skipped. Rows that scrolled out of the viewport during drag-to-scroll + * are joined back in from the scrolledOffAbove/Below accumulators along + * with their captured softWrap bits. + */ +export function getSelectedText(s: SelectionState, screen: Screen): string { + const b = selectionBounds(s) + + if (!b) { + return '' + } + + const { start, end } = b + const sw = screen.softWrap + const lines: string[] = [] + + for (let i = 0; i < s.scrolledOffAbove.length; i++) { + joinRows(lines, s.scrolledOffAbove[i]!, s.scrolledOffAboveSW[i]) + } + + for (let row = start.row; row <= end.row; row++) { + const rowStart = row === start.row ? start.col : 0 + const rowEnd = row === end.row ? end.col : screen.width - 1 + joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0) + } + + for (let i = 0; i < s.scrolledOffBelow.length; i++) { + joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i]) + } + + return lines.join('\n') +} + +/** + * Capture text from rows about to scroll out of the viewport during + * drag-to-scroll, BEFORE scrollBy overwrites them. Only the rows that + * intersect the selection are captured, using the selection's col bounds + * for the anchor-side boundary row. After capturing the anchor row, the + * anchor.col AND anchorSpan cols are reset to the full-width boundary so + * subsequent captures and the final getSelectedText don't re-apply a stale + * col constraint to content that's no longer under the original anchor. + * Both span cols are reset (not just the near side): after a blocked + * reversal the drag can flip direction, and extendSelection then reads the + * OPPOSITE span side — which would otherwise still hold the original word + * boundary and truncate one subsequently-captured row. + * + * side='above': rows scrolling out the top (dragging down, anchor=start). + * side='below': rows scrolling out the bottom (dragging up, anchor=end). + */ +export function captureScrolledRows( + s: SelectionState, + screen: Screen, + firstRow: number, + lastRow: number, + side: 'above' | 'below' +): void { + const b = selectionBounds(s) + + if (!b || firstRow > lastRow) { + return + } + + const { start, end } = b + // Intersect [firstRow, lastRow] with [start.row, end.row]. Rows outside + // the selection aren't captured — they weren't selected. + const lo = Math.max(firstRow, start.row) + const hi = Math.min(lastRow, end.row) + + if (lo > hi) { + return + } + + const width = screen.width + const sw = screen.softWrap + const captured: string[] = [] + const capturedSW: boolean[] = [] + + for (let row = lo; row <= hi; row++) { + const colStart = row === start.row ? start.col : 0 + const colEnd = row === end.row ? end.col : width - 1 + captured.push(extractRowText(screen, row, colStart, colEnd)) + capturedSW.push(sw[row]! > 0) + } + + if (side === 'above') { + // Newest rows go at the bottom of the above-accumulator (closest to + // the on-screen content in reading order). + s.scrolledOffAbove.push(...captured) + s.scrolledOffAboveSW.push(...capturedSW) + + // We just captured the top of the selection. The anchor (=start when + // dragging down) is now pointing at content that will scroll out; its + // col constraint was applied to the captured row. Reset to col 0 so + // the NEXT tick and the final getSelectedText read the full row. + if (s.anchor && s.anchor.row === start.row && lo === start.row) { + s.anchor = { col: 0, row: s.anchor.row } + + if (s.anchorSpan) { + s.anchorSpan = { + kind: s.anchorSpan.kind, + lo: { col: 0, row: s.anchorSpan.lo.row }, + hi: { col: width - 1, row: s.anchorSpan.hi.row } + } + } + } + } else { + // Newest rows go at the TOP of the below-accumulator — they're + // closest to the on-screen content. + s.scrolledOffBelow.unshift(...captured) + s.scrolledOffBelowSW.unshift(...capturedSW) + + if (s.anchor && s.anchor.row === end.row && hi === end.row) { + s.anchor = { col: width - 1, row: s.anchor.row } + + if (s.anchorSpan) { + s.anchorSpan = { + kind: s.anchorSpan.kind, + lo: { col: 0, row: s.anchorSpan.lo.row }, + hi: { col: width - 1, row: s.anchorSpan.hi.row } + } + } + } + } +} + +/** + * Apply the selection overlay directly to the screen buffer by changing + * the style of every cell in the selection range. Called after the + * renderer produces the Frame but before the diff — the normal diffEach + * then picks up the restyled cells as ordinary changes, so LogUpdate + * stays a pure diff engine with no selection awareness. + * + * Uses a SOLID selection background (theme-provided via StylePool. + * setSelectionBg) that REPLACES each cell's bg while PRESERVING its fg — + * matches native terminal selection. Previously SGR-7 inverse (swapped + * fg/bg per cell), which fragmented badly over syntax-highlighted text: + * every distinct fg color became a different bg stripe. + * + * Uses StylePool caches so on drag the only work per cell is a Map + * lookup + packed-int write. + */ +export function applySelectionOverlay(screen: Screen, selection: SelectionState, stylePool: StylePool): void { + const b = selectionBounds(selection) + + if (!b) { + return + } + + const { start, end } = b + const width = screen.width + const noSelect = screen.noSelect + + for (let row = start.row; row <= end.row && row < screen.height; row++) { + const colStart = row === start.row ? start.col : 0 + const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1 + const rowOff = row * width + + for (let col = colStart; col <= colEnd; col++) { + const idx = rowOff + col + + // Skip noSelect cells — gutters stay visually unchanged so it's + // clear they're not part of the copy. Surrounding selectable cells + // still highlight so the selection extent remains visible. + if (noSelect[idx] === 1) { + continue + } + + const cell = cellAtIndex(screen, idx) + setCellStyleId(screen, col, row, stylePool.withSelectionBg(cell.styleId)) + } + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts b/ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts new file mode 100644 index 0000000000..edb26b3b69 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/squash-text-nodes.ts @@ -0,0 +1,74 @@ +import type { DOMElement } from './dom.js' +import type { TextStyles } from './styles.js' + +/** + * A segment of text with its associated styles. + * Used for structured rendering without ANSI string transforms. + */ +export type StyledSegment = { + text: string + styles: TextStyles + hyperlink?: string +} + +/** + * Squash text nodes into styled segments, propagating styles down through the tree. + * This allows structured styling without relying on ANSI string transforms. + */ +export function squashTextNodesToSegments( + node: DOMElement, + inheritedStyles: TextStyles = {}, + inheritedHyperlink?: string, + out: StyledSegment[] = [] +): StyledSegment[] { + const mergedStyles = node.textStyles ? { ...inheritedStyles, ...node.textStyles } : inheritedStyles + + for (const childNode of node.childNodes) { + if (childNode === undefined) { + continue + } + + if (childNode.nodeName === '#text') { + if (childNode.nodeValue.length > 0) { + out.push({ + text: childNode.nodeValue, + styles: mergedStyles, + hyperlink: inheritedHyperlink + }) + } + } else if (childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text') { + squashTextNodesToSegments(childNode, mergedStyles, inheritedHyperlink, out) + } else if (childNode.nodeName === 'ink-link') { + const href = childNode.attributes['href'] as string | undefined + squashTextNodesToSegments(childNode, mergedStyles, href || inheritedHyperlink, out) + } + } + + return out +} + +/** + * Squash text nodes into a plain string (without styles). + * Used for text measurement in layout calculations. + */ +function squashTextNodes(node: DOMElement): string { + let text = '' + + for (const childNode of node.childNodes) { + if (childNode === undefined) { + continue + } + + if (childNode.nodeName === '#text') { + text += childNode.nodeValue + } else if (childNode.nodeName === 'ink-text' || childNode.nodeName === 'ink-virtual-text') { + text += squashTextNodes(childNode) + } else if (childNode.nodeName === 'ink-link') { + text += squashTextNodes(childNode) + } + } + + return text +} + +export default squashTextNodes diff --git a/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts new file mode 100644 index 0000000000..0b97ac1519 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/stringWidth.ts @@ -0,0 +1,275 @@ +import emojiRegex from 'emoji-regex' +import { eastAsianWidth } from 'get-east-asian-width' +import stripAnsi from 'strip-ansi' + +import { getGraphemeSegmenter } from '../utils/intl.js' + +const EMOJI_REGEX = emojiRegex() + +/** + * Fallback JavaScript implementation of stringWidth when Bun.stringWidth is not available. + * + * Get the display width of a string as it would appear in a terminal. + * + * This is a more accurate alternative to the string-width package that correctly handles + * characters like ⚠ (U+26A0) which string-width incorrectly reports as width 2. + * + * The implementation uses eastAsianWidth directly with ambiguousAsWide: false, + * which correctly treats ambiguous-width characters as narrow (width 1) as + * recommended by the Unicode standard for Western contexts. + */ +function stringWidthJavaScript(str: string): number { + if (typeof str !== 'string' || str.length === 0) { + return 0 + } + + // Fast path: pure ASCII string (no ANSI codes, no wide chars) + let isPureAscii = true + + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i) + + // Check for non-ASCII or ANSI escape (0x1b) + if (code >= 127 || code === 0x1b) { + isPureAscii = false + + break + } + } + + if (isPureAscii) { + // Count printable characters (exclude control chars) + let width = 0 + + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i) + + if (code > 0x1f) { + width++ + } + } + + return width + } + + // Strip ANSI if escape character is present + if (str.includes('\x1b')) { + str = stripAnsi(str) + + if (str.length === 0) { + return 0 + } + } + + // Fast path: simple Unicode (no emoji, variation selectors, or joiners) + if (!needsSegmentation(str)) { + let width = 0 + + for (const char of str) { + const codePoint = char.codePointAt(0)! + + if (!isZeroWidth(codePoint)) { + width += eastAsianWidth(codePoint, { ambiguousAsWide: false }) + } + } + + return width + } + + let width = 0 + + for (const { segment: grapheme } of getGraphemeSegmenter().segment(str)) { + // Check for emoji first (most emoji sequences are width 2) + EMOJI_REGEX.lastIndex = 0 + + if (EMOJI_REGEX.test(grapheme)) { + width += getEmojiWidth(grapheme) + + continue + } + + // Calculate width for non-emoji graphemes + // For grapheme clusters (like Devanagari conjuncts with virama+ZWJ), only count + // the first non-zero-width character's width since the cluster renders as one glyph + for (const char of grapheme) { + const codePoint = char.codePointAt(0)! + + if (!isZeroWidth(codePoint)) { + width += eastAsianWidth(codePoint, { ambiguousAsWide: false }) + + break + } + } + } + + return width +} + +function needsSegmentation(str: string): boolean { + for (const char of str) { + const cp = char.codePointAt(0)! + + // Emoji ranges + if (cp >= 0x1f300 && cp <= 0x1faff) { + return true + } + + if (cp >= 0x2600 && cp <= 0x27bf) { + return true + } + + if (cp >= 0x1f1e6 && cp <= 0x1f1ff) { + return true + } + + // Variation selectors, ZWJ + if (cp >= 0xfe00 && cp <= 0xfe0f) { + return true + } + + if (cp === 0x200d) { + return true + } + } + + return false +} + +function getEmojiWidth(grapheme: string): number { + // Regional indicators: single = 1, pair = 2 + const first = grapheme.codePointAt(0)! + + if (first >= 0x1f1e6 && first <= 0x1f1ff) { + let count = 0 + + for (const _ of grapheme) { + count++ + } + + return count === 1 ? 1 : 2 + } + + // Incomplete keycap: digit/symbol + VS16 without U+20E3 + if (grapheme.length === 2) { + const second = grapheme.codePointAt(1) + + if (second === 0xfe0f && ((first >= 0x30 && first <= 0x39) || first === 0x23 || first === 0x2a)) { + return 1 + } + } + + return 2 +} + +function isZeroWidth(codePoint: number): boolean { + // Fast path for common printable range + if (codePoint >= 0x20 && codePoint < 0x7f) { + return false + } + + if (codePoint >= 0xa0 && codePoint < 0x0300) { + return codePoint === 0x00ad + } + + // Control characters + if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { + return true + } + + // Zero-width and invisible characters + if ( + (codePoint >= 0x200b && codePoint <= 0x200d) || // ZW space/joiner + codePoint === 0xfeff || // BOM + (codePoint >= 0x2060 && codePoint <= 0x2064) // Word joiner etc. + ) { + return true + } + + // Variation selectors + if ((codePoint >= 0xfe00 && codePoint <= 0xfe0f) || (codePoint >= 0xe0100 && codePoint <= 0xe01ef)) { + return true + } + + // Combining diacritical marks + if ( + (codePoint >= 0x0300 && codePoint <= 0x036f) || + (codePoint >= 0x1ab0 && codePoint <= 0x1aff) || + (codePoint >= 0x1dc0 && codePoint <= 0x1dff) || + (codePoint >= 0x20d0 && codePoint <= 0x20ff) || + (codePoint >= 0xfe20 && codePoint <= 0xfe2f) + ) { + return true + } + + // Indic script combining marks (covers Devanagari through Malayalam) + if (codePoint >= 0x0900 && codePoint <= 0x0d4f) { + // Signs and vowel marks at start of each script block + const offset = codePoint & 0x7f + + if (offset <= 0x03) { + return true + } // Signs at block start + + if (offset >= 0x3a && offset <= 0x4f) { + return true + } // Vowel signs, virama + + if (offset >= 0x51 && offset <= 0x57) { + return true + } // Stress signs + + if (offset >= 0x62 && offset <= 0x63) { + return true + } // Vowel signs + } + + // Thai/Lao combining marks + // Note: U+0E32 (SARA AA), U+0E33 (SARA AM), U+0EB2, U+0EB3 are spacing vowels (width 1), not combining marks + if ( + codePoint === 0x0e31 || // Thai MAI HAN-AKAT + (codePoint >= 0x0e34 && codePoint <= 0x0e3a) || // Thai vowel signs (skip U+0E32, U+0E33) + (codePoint >= 0x0e47 && codePoint <= 0x0e4e) || // Thai vowel signs and marks + codePoint === 0x0eb1 || // Lao MAI KAN + (codePoint >= 0x0eb4 && codePoint <= 0x0ebc) || // Lao vowel signs (skip U+0EB2, U+0EB3) + (codePoint >= 0x0ec8 && codePoint <= 0x0ecd) // Lao tone marks + ) { + return true + } + + // Arabic formatting + if ( + (codePoint >= 0x0600 && codePoint <= 0x0605) || + codePoint === 0x06dd || + codePoint === 0x070f || + codePoint === 0x08e2 + ) { + return true + } + + // Surrogates, tag characters + if (codePoint >= 0xd800 && codePoint <= 0xdfff) { + return true + } + + if (codePoint >= 0xe0000 && codePoint <= 0xe007f) { + return true + } + + return false +} + +// Note: complex-script graphemes like Devanagari क्ष (ka+virama+ZWJ+ssa) render +// as a single ligature glyph but occupy 2 terminal cells (wcwidth sums the base +// consonants). Bun.stringWidth=2 matches terminal cell allocation, which is what +// we need for cursor positioning — the JS fallback's grapheme-cluster width of 1 +// would desync Ink's layout from the terminal. +// +// Bun.stringWidth is resolved once at module scope rather than checked on every +// call — typeof guards deopt property access and this is a hot path (~100k calls/frame). +const bunStringWidth = typeof Bun !== 'undefined' && typeof Bun.stringWidth === 'function' ? Bun.stringWidth : null + +const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const + +export const stringWidth: (str: string) => number = bunStringWidth + ? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS) + : stringWidthJavaScript diff --git a/ui-tui/packages/hermes-ink/src/ink/styles.ts b/ui-tui/packages/hermes-ink/src/ink/styles.ts new file mode 100644 index 0000000000..e5321f6e50 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/styles.ts @@ -0,0 +1,749 @@ +import { + LayoutAlign, + LayoutDisplay, + LayoutEdge, + LayoutFlexDirection, + LayoutGutter, + LayoutJustify, + type LayoutNode, + LayoutOverflow, + LayoutPositionType, + LayoutWrap +} from './layout/node.js' +import type { BorderStyle, BorderTextOptions } from './render-border.js' + +export type RGBColor = `rgb(${number},${number},${number})` +export type HexColor = `#${string}` +export type Ansi256Color = `ansi256(${number})` +export type AnsiColor = + | 'ansi:black' + | 'ansi:red' + | 'ansi:green' + | 'ansi:yellow' + | 'ansi:blue' + | 'ansi:magenta' + | 'ansi:cyan' + | 'ansi:white' + | 'ansi:blackBright' + | 'ansi:redBright' + | 'ansi:greenBright' + | 'ansi:yellowBright' + | 'ansi:blueBright' + | 'ansi:magentaBright' + | 'ansi:cyanBright' + | 'ansi:whiteBright' + +/** Raw color value - not a theme key */ +export type Color = RGBColor | HexColor | Ansi256Color | AnsiColor + +/** + * Structured text styling properties. + * Used to style text without relying on ANSI string transforms. + * Colors are raw values - theme resolution happens at the component layer. + */ +export type TextStyles = { + readonly color?: Color + readonly backgroundColor?: Color + readonly dim?: boolean + readonly bold?: boolean + readonly italic?: boolean + readonly underline?: boolean + readonly strikethrough?: boolean + readonly inverse?: boolean +} + +export type Styles = { + readonly textWrap?: + | 'wrap' + | 'wrap-trim' + | 'end' + | 'middle' + | 'truncate-end' + | 'truncate' + | 'truncate-middle' + | 'truncate-start' + + readonly position?: 'absolute' | 'relative' + readonly top?: number | `${number}%` + readonly bottom?: number | `${number}%` + readonly left?: number | `${number}%` + readonly right?: number | `${number}%` + + /** + * Size of the gap between an element's columns. + */ + readonly columnGap?: number + + /** + * Size of the gap between element's rows. + */ + readonly rowGap?: number + + /** + * Size of the gap between an element's columns and rows. Shorthand for `columnGap` and `rowGap`. + */ + readonly gap?: number + + /** + * Margin on all sides. Equivalent to setting `marginTop`, `marginBottom`, `marginLeft` and `marginRight`. + */ + readonly margin?: number + + /** + * Horizontal margin. Equivalent to setting `marginLeft` and `marginRight`. + */ + readonly marginX?: number + + /** + * Vertical margin. Equivalent to setting `marginTop` and `marginBottom`. + */ + readonly marginY?: number + + /** + * Top margin. + */ + readonly marginTop?: number + + /** + * Bottom margin. + */ + readonly marginBottom?: number + + /** + * Left margin. + */ + readonly marginLeft?: number + + /** + * Right margin. + */ + readonly marginRight?: number + + /** + * Padding on all sides. Equivalent to setting `paddingTop`, `paddingBottom`, `paddingLeft` and `paddingRight`. + */ + readonly padding?: number + + /** + * Horizontal padding. Equivalent to setting `paddingLeft` and `paddingRight`. + */ + readonly paddingX?: number + + /** + * Vertical padding. Equivalent to setting `paddingTop` and `paddingBottom`. + */ + readonly paddingY?: number + + /** + * Top padding. + */ + readonly paddingTop?: number + + /** + * Bottom padding. + */ + readonly paddingBottom?: number + + /** + * Left padding. + */ + readonly paddingLeft?: number + + /** + * Right padding. + */ + readonly paddingRight?: number + + /** + * This property defines the ability for a flex item to grow if necessary. + * See [flex-grow](https://css-tricks.com/almanac/properties/f/flex-grow/). + */ + readonly flexGrow?: number + + /** + * It specifies the “flex shrink factor”, which determines how much the flex item will shrink relative to the rest of the flex items in the flex container when there isn’t enough space on the row. + * See [flex-shrink](https://css-tricks.com/almanac/properties/f/flex-shrink/). + */ + readonly flexShrink?: number + + /** + * It establishes the main-axis, thus defining the direction flex items are placed in the flex container. + * See [flex-direction](https://css-tricks.com/almanac/properties/f/flex-direction/). + */ + readonly flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse' + + /** + * It specifies the initial size of the flex item, before any available space is distributed according to the flex factors. + * See [flex-basis](https://css-tricks.com/almanac/properties/f/flex-basis/). + */ + readonly flexBasis?: number | string + + /** + * It defines whether the flex items are forced in a single line or can be flowed into multiple lines. If set to multiple lines, it also defines the cross-axis which determines the direction new lines are stacked in. + * See [flex-wrap](https://css-tricks.com/almanac/properties/f/flex-wrap/). + */ + readonly flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse' + + /** + * The align-items property defines the default behavior for how items are laid out along the cross axis (perpendicular to the main axis). + * See [align-items](https://css-tricks.com/almanac/properties/a/align-items/). + */ + readonly alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch' + + /** + * It makes possible to override the align-items value for specific flex items. + * See [align-self](https://css-tricks.com/almanac/properties/a/align-self/). + */ + readonly alignSelf?: 'flex-start' | 'center' | 'flex-end' | 'auto' + + /** + * It defines the alignment along the main axis. + * See [justify-content](https://css-tricks.com/almanac/properties/j/justify-content/). + */ + readonly justifyContent?: 'flex-start' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly' | 'center' + + /** + * Width of the element in spaces. + * You can also set it in percent, which will calculate the width based on the width of parent element. + */ + readonly width?: number | string + + /** + * Height of the element in lines (rows). + * You can also set it in percent, which will calculate the height based on the height of parent element. + */ + readonly height?: number | string + + /** + * Sets a minimum width of the element. + */ + readonly minWidth?: number | string + + /** + * Sets a minimum height of the element. + */ + readonly minHeight?: number | string + + /** + * Sets a maximum width of the element. + */ + readonly maxWidth?: number | string + + /** + * Sets a maximum height of the element. + */ + readonly maxHeight?: number | string + + /** + * Set this property to `none` to hide the element. + */ + readonly display?: 'flex' | 'none' + + /** + * Add a border with a specified style. + * If `borderStyle` is `undefined` (which it is by default), no border will be added. + */ + readonly borderStyle?: BorderStyle + + /** + * Determines whether top border is visible. + * + * @default true + */ + readonly borderTop?: boolean + + /** + * Determines whether bottom border is visible. + * + * @default true + */ + readonly borderBottom?: boolean + + /** + * Determines whether left border is visible. + * + * @default true + */ + readonly borderLeft?: boolean + + /** + * Determines whether right border is visible. + * + * @default true + */ + readonly borderRight?: boolean + + /** + * Change border color. + * Shorthand for setting `borderTopColor`, `borderRightColor`, `borderBottomColor` and `borderLeftColor`. + */ + readonly borderColor?: Color + + /** + * Change top border color. + * Accepts raw color values (rgb, hex, ansi). + */ + readonly borderTopColor?: Color + + /** + * Change bottom border color. + * Accepts raw color values (rgb, hex, ansi). + */ + readonly borderBottomColor?: Color + + /** + * Change left border color. + * Accepts raw color values (rgb, hex, ansi). + */ + readonly borderLeftColor?: Color + + /** + * Change right border color. + * Accepts raw color values (rgb, hex, ansi). + */ + readonly borderRightColor?: Color + + /** + * Dim the border color. + * Shorthand for setting `borderTopDimColor`, `borderBottomDimColor`, `borderLeftDimColor` and `borderRightDimColor`. + * + * @default false + */ + readonly borderDimColor?: boolean + + /** + * Dim the top border color. + * + * @default false + */ + readonly borderTopDimColor?: boolean + + /** + * Dim the bottom border color. + * + * @default false + */ + readonly borderBottomDimColor?: boolean + + /** + * Dim the left border color. + * + * @default false + */ + readonly borderLeftDimColor?: boolean + + /** + * Dim the right border color. + * + * @default false + */ + readonly borderRightDimColor?: boolean + + /** + * Add text within the border. Only applies to top or bottom borders. + */ + readonly borderText?: BorderTextOptions + + /** + * Background color for the box. Fills the interior with background-colored + * spaces and is inherited by child text nodes as their default background. + */ + readonly backgroundColor?: Color + + /** + * Fill the box's interior (padding included) with spaces before + * rendering children, so nothing behind it shows through. Like + * `backgroundColor` but without emitting any SGR — the terminal's + * default background is used. Useful for absolute-positioned overlays + * where Box padding/gaps would otherwise be transparent. + */ + readonly opaque?: boolean + + /** + * Behavior for an element's overflow in both directions. + * 'scroll' constrains the container's size (children do not expand it) + * and enables scrollTop-based virtualized scrolling at render time. + * + * @default 'visible' + */ + readonly overflow?: 'visible' | 'hidden' | 'scroll' + + /** + * Behavior for an element's overflow in horizontal direction. + * + * @default 'visible' + */ + readonly overflowX?: 'visible' | 'hidden' | 'scroll' + + /** + * Behavior for an element's overflow in vertical direction. + * + * @default 'visible' + */ + readonly overflowY?: 'visible' | 'hidden' | 'scroll' + + /** + * Exclude this box's cells from text selection in fullscreen mode. + * Cells inside this region are skipped by both the selection highlight + * and the copied text — useful for fencing off gutters (line numbers, + * diff sigils) so click-drag over a diff yields clean copyable code. + * Only affects alt-screen text selection; no-op otherwise. + * + * `'from-left-edge'` extends the exclusion from column 0 to the box's + * right edge for every row it occupies — this covers any upstream + * indentation (tool message prefix, tree lines) so a multi-row drag + * doesn't pick up leading whitespace from middle rows. + */ + readonly noSelect?: boolean | 'from-left-edge' +} + +const applyPositionStyles = (node: LayoutNode, style: Styles): void => { + if ('position' in style) { + node.setPositionType(style.position === 'absolute' ? LayoutPositionType.Absolute : LayoutPositionType.Relative) + } + + if ('top' in style) { + applyPositionEdge(node, 'top', style.top) + } + + if ('bottom' in style) { + applyPositionEdge(node, 'bottom', style.bottom) + } + + if ('left' in style) { + applyPositionEdge(node, 'left', style.left) + } + + if ('right' in style) { + applyPositionEdge(node, 'right', style.right) + } +} + +function applyPositionEdge( + node: LayoutNode, + edge: 'top' | 'bottom' | 'left' | 'right', + v: number | `${number}%` | undefined +): void { + if (typeof v === 'string') { + node.setPositionPercent(edge, Number.parseInt(v, 10)) + } else if (typeof v === 'number') { + node.setPosition(edge, v) + } else { + node.setPosition(edge, Number.NaN) + } +} + +const applyOverflowStyles = (node: LayoutNode, style: Styles): void => { + // Yoga's Overflow controls whether children expand the container. + // 'hidden' and 'scroll' both prevent expansion; 'scroll' additionally + // signals that the renderer should apply scrollTop translation. + // overflowX/Y are render-time concerns; for layout we use the union. + const y = style.overflowY ?? style.overflow + const x = style.overflowX ?? style.overflow + + if (y === 'scroll' || x === 'scroll') { + node.setOverflow(LayoutOverflow.Scroll) + } else if (y === 'hidden' || x === 'hidden') { + node.setOverflow(LayoutOverflow.Hidden) + } else if ('overflow' in style || 'overflowX' in style || 'overflowY' in style) { + node.setOverflow(LayoutOverflow.Visible) + } +} + +const applyMarginStyles = (node: LayoutNode, style: Styles): void => { + if ('margin' in style) { + node.setMargin(LayoutEdge.All, style.margin ?? 0) + } + + if ('marginX' in style) { + node.setMargin(LayoutEdge.Horizontal, style.marginX ?? 0) + } + + if ('marginY' in style) { + node.setMargin(LayoutEdge.Vertical, style.marginY ?? 0) + } + + if ('marginLeft' in style) { + node.setMargin(LayoutEdge.Start, style.marginLeft || 0) + } + + if ('marginRight' in style) { + node.setMargin(LayoutEdge.End, style.marginRight || 0) + } + + if ('marginTop' in style) { + node.setMargin(LayoutEdge.Top, style.marginTop || 0) + } + + if ('marginBottom' in style) { + node.setMargin(LayoutEdge.Bottom, style.marginBottom || 0) + } +} + +const applyPaddingStyles = (node: LayoutNode, style: Styles): void => { + if ('padding' in style) { + node.setPadding(LayoutEdge.All, style.padding ?? 0) + } + + if ('paddingX' in style) { + node.setPadding(LayoutEdge.Horizontal, style.paddingX ?? 0) + } + + if ('paddingY' in style) { + node.setPadding(LayoutEdge.Vertical, style.paddingY ?? 0) + } + + if ('paddingLeft' in style) { + node.setPadding(LayoutEdge.Left, style.paddingLeft || 0) + } + + if ('paddingRight' in style) { + node.setPadding(LayoutEdge.Right, style.paddingRight || 0) + } + + if ('paddingTop' in style) { + node.setPadding(LayoutEdge.Top, style.paddingTop || 0) + } + + if ('paddingBottom' in style) { + node.setPadding(LayoutEdge.Bottom, style.paddingBottom || 0) + } +} + +const applyFlexStyles = (node: LayoutNode, style: Styles): void => { + if ('flexGrow' in style) { + node.setFlexGrow(style.flexGrow ?? 0) + } + + if ('flexShrink' in style) { + node.setFlexShrink(typeof style.flexShrink === 'number' ? style.flexShrink : 1) + } + + if ('flexWrap' in style) { + if (style.flexWrap === 'nowrap') { + node.setFlexWrap(LayoutWrap.NoWrap) + } + + if (style.flexWrap === 'wrap') { + node.setFlexWrap(LayoutWrap.Wrap) + } + + if (style.flexWrap === 'wrap-reverse') { + node.setFlexWrap(LayoutWrap.WrapReverse) + } + } + + if ('flexDirection' in style) { + if (style.flexDirection === 'row') { + node.setFlexDirection(LayoutFlexDirection.Row) + } + + if (style.flexDirection === 'row-reverse') { + node.setFlexDirection(LayoutFlexDirection.RowReverse) + } + + if (style.flexDirection === 'column') { + node.setFlexDirection(LayoutFlexDirection.Column) + } + + if (style.flexDirection === 'column-reverse') { + node.setFlexDirection(LayoutFlexDirection.ColumnReverse) + } + } + + if ('flexBasis' in style) { + if (typeof style.flexBasis === 'number') { + node.setFlexBasis(style.flexBasis) + } else if (typeof style.flexBasis === 'string') { + node.setFlexBasisPercent(Number.parseInt(style.flexBasis, 10)) + } else { + node.setFlexBasis(Number.NaN) + } + } + + if ('alignItems' in style) { + if (style.alignItems === 'stretch' || !style.alignItems) { + node.setAlignItems(LayoutAlign.Stretch) + } + + if (style.alignItems === 'flex-start') { + node.setAlignItems(LayoutAlign.FlexStart) + } + + if (style.alignItems === 'center') { + node.setAlignItems(LayoutAlign.Center) + } + + if (style.alignItems === 'flex-end') { + node.setAlignItems(LayoutAlign.FlexEnd) + } + } + + if ('alignSelf' in style) { + if (style.alignSelf === 'auto' || !style.alignSelf) { + node.setAlignSelf(LayoutAlign.Auto) + } + + if (style.alignSelf === 'flex-start') { + node.setAlignSelf(LayoutAlign.FlexStart) + } + + if (style.alignSelf === 'center') { + node.setAlignSelf(LayoutAlign.Center) + } + + if (style.alignSelf === 'flex-end') { + node.setAlignSelf(LayoutAlign.FlexEnd) + } + } + + if ('justifyContent' in style) { + if (style.justifyContent === 'flex-start' || !style.justifyContent) { + node.setJustifyContent(LayoutJustify.FlexStart) + } + + if (style.justifyContent === 'center') { + node.setJustifyContent(LayoutJustify.Center) + } + + if (style.justifyContent === 'flex-end') { + node.setJustifyContent(LayoutJustify.FlexEnd) + } + + if (style.justifyContent === 'space-between') { + node.setJustifyContent(LayoutJustify.SpaceBetween) + } + + if (style.justifyContent === 'space-around') { + node.setJustifyContent(LayoutJustify.SpaceAround) + } + + if (style.justifyContent === 'space-evenly') { + node.setJustifyContent(LayoutJustify.SpaceEvenly) + } + } +} + +const applyDimensionStyles = (node: LayoutNode, style: Styles): void => { + if ('width' in style) { + if (typeof style.width === 'number') { + node.setWidth(style.width) + } else if (typeof style.width === 'string') { + node.setWidthPercent(Number.parseInt(style.width, 10)) + } else { + node.setWidthAuto() + } + } + + if ('height' in style) { + if (typeof style.height === 'number') { + node.setHeight(style.height) + } else if (typeof style.height === 'string') { + node.setHeightPercent(Number.parseInt(style.height, 10)) + } else { + node.setHeightAuto() + } + } + + if ('minWidth' in style) { + if (typeof style.minWidth === 'string') { + node.setMinWidthPercent(Number.parseInt(style.minWidth, 10)) + } else { + node.setMinWidth(style.minWidth ?? 0) + } + } + + if ('minHeight' in style) { + if (typeof style.minHeight === 'string') { + node.setMinHeightPercent(Number.parseInt(style.minHeight, 10)) + } else { + node.setMinHeight(style.minHeight ?? 0) + } + } + + if ('maxWidth' in style) { + if (typeof style.maxWidth === 'string') { + node.setMaxWidthPercent(Number.parseInt(style.maxWidth, 10)) + } else { + node.setMaxWidth(style.maxWidth ?? 0) + } + } + + if ('maxHeight' in style) { + if (typeof style.maxHeight === 'string') { + node.setMaxHeightPercent(Number.parseInt(style.maxHeight, 10)) + } else { + node.setMaxHeight(style.maxHeight ?? 0) + } + } +} + +const applyDisplayStyles = (node: LayoutNode, style: Styles): void => { + if ('display' in style) { + node.setDisplay(style.display === 'flex' ? LayoutDisplay.Flex : LayoutDisplay.None) + } +} + +const applyBorderStyles = (node: LayoutNode, style: Styles, resolvedStyle?: Styles): void => { + // resolvedStyle is the full current style (already set on the DOM node). + // style may be a diff with only changed properties. For border side props, + // we need the resolved value because `borderStyle` in a diff may not include + // unchanged border side values (e.g. borderTop stays false but isn't in the diff). + const resolved = resolvedStyle ?? style + + if ('borderStyle' in style) { + const borderWidth = style.borderStyle ? 1 : 0 + + node.setBorder(LayoutEdge.Top, resolved.borderTop !== false ? borderWidth : 0) + node.setBorder(LayoutEdge.Bottom, resolved.borderBottom !== false ? borderWidth : 0) + node.setBorder(LayoutEdge.Left, resolved.borderLeft !== false ? borderWidth : 0) + node.setBorder(LayoutEdge.Right, resolved.borderRight !== false ? borderWidth : 0) + } else { + // Handle individual border property changes (when only borderX changes without borderStyle). + // Skip undefined values — they mean the prop was removed or never set, + // not that a border should be enabled. + if ('borderTop' in style && style.borderTop !== undefined) { + node.setBorder(LayoutEdge.Top, style.borderTop === false ? 0 : 1) + } + + if ('borderBottom' in style && style.borderBottom !== undefined) { + node.setBorder(LayoutEdge.Bottom, style.borderBottom === false ? 0 : 1) + } + + if ('borderLeft' in style && style.borderLeft !== undefined) { + node.setBorder(LayoutEdge.Left, style.borderLeft === false ? 0 : 1) + } + + if ('borderRight' in style && style.borderRight !== undefined) { + node.setBorder(LayoutEdge.Right, style.borderRight === false ? 0 : 1) + } + } +} + +const applyGapStyles = (node: LayoutNode, style: Styles): void => { + if ('gap' in style) { + node.setGap(LayoutGutter.All, style.gap ?? 0) + } + + if ('columnGap' in style) { + node.setGap(LayoutGutter.Column, style.columnGap ?? 0) + } + + if ('rowGap' in style) { + node.setGap(LayoutGutter.Row, style.rowGap ?? 0) + } +} + +const styles = (node: LayoutNode, style: Styles = {}, resolvedStyle?: Styles): void => { + applyPositionStyles(node, style) + applyOverflowStyles(node, style) + applyMarginStyles(node, style) + applyPaddingStyles(node, style) + applyFlexStyles(node, style) + applyDimensionStyles(node, style) + applyDisplayStyles(node, style) + applyBorderStyles(node, style, resolvedStyle) + applyGapStyles(node, style) +} + +export default styles diff --git a/ui-tui/packages/hermes-ink/src/ink/supports-hyperlinks.ts b/ui-tui/packages/hermes-ink/src/ink/supports-hyperlinks.ts new file mode 100644 index 0000000000..16aed4a6c0 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/supports-hyperlinks.ts @@ -0,0 +1,51 @@ +import supportsHyperlinksLib from 'supports-hyperlinks' + +// Additional terminals that support OSC 8 hyperlinks but aren't detected by supports-hyperlinks. +// Checked against both TERM_PROGRAM and LC_TERMINAL (the latter is preserved inside tmux). +export const ADDITIONAL_HYPERLINK_TERMINALS = ['ghostty', 'Hyper', 'kitty', 'alacritty', 'iTerm.app', 'iTerm2'] + +type EnvLike = Record + +type SupportsHyperlinksOptions = { + env?: EnvLike + stdoutSupported?: boolean +} + +/** + * Returns whether stdout supports OSC 8 hyperlinks. + * Extends the supports-hyperlinks library with additional terminal detection. + * @param options Optional overrides for testing (env, stdoutSupported) + */ +export function supportsHyperlinks(options?: SupportsHyperlinksOptions): boolean { + const stdoutSupported = options?.stdoutSupported ?? supportsHyperlinksLib.stdout + + if (stdoutSupported) { + return true + } + + const env = options?.env ?? process.env + + // Check for additional terminals not detected by supports-hyperlinks + const termProgram = env['TERM_PROGRAM'] + + if (termProgram && ADDITIONAL_HYPERLINK_TERMINALS.includes(termProgram)) { + return true + } + + // LC_TERMINAL is set by some terminals (e.g. iTerm2) and preserved inside tmux, + // where TERM_PROGRAM is overwritten to 'tmux'. + const lcTerminal = env['LC_TERMINAL'] + + if (lcTerminal && ADDITIONAL_HYPERLINK_TERMINALS.includes(lcTerminal)) { + return true + } + + // Kitty sets TERM=xterm-kitty + const term = env['TERM'] + + if (term?.includes('kitty')) { + return true + } + + return false +} diff --git a/ui-tui/packages/hermes-ink/src/ink/tabstops.ts b/ui-tui/packages/hermes-ink/src/ink/tabstops.ts new file mode 100644 index 0000000000..9b6007b101 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/tabstops.ts @@ -0,0 +1,44 @@ +// Tab expansion, inspired by Ghostty's Tabstops.zig +// Uses 8-column intervals (POSIX default, hardcoded in terminals like Ghostty) + +import { stringWidth } from './stringWidth.js' +import { createTokenizer } from './termio/tokenize.js' + +const DEFAULT_TAB_INTERVAL = 8 + +export function expandTabs(text: string, interval = DEFAULT_TAB_INTERVAL): string { + if (!text.includes('\t')) { + return text + } + + const tokenizer = createTokenizer() + const tokens = tokenizer.feed(text) + tokens.push(...tokenizer.flush()) + + let result = '' + let column = 0 + + for (const token of tokens) { + if (token.type === 'sequence') { + result += token.value + } else { + const parts = token.value.split(/(\t|\n)/) + + for (const part of parts) { + if (part === '\t') { + const spaces = interval - (column % interval) + result += ' '.repeat(spaces) + column += spaces + } else if (part === '\n') { + result += part + column = 0 + } else { + result += part + column += stringWidth(part) + } + } + } + } + + return result +} diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-focus-state.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-focus-state.ts new file mode 100644 index 0000000000..ed6c60ab4d --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-focus-state.ts @@ -0,0 +1,52 @@ +// Terminal focus state signal — non-React access to DECSET 1004 focus events. +// 'unknown' is the default for terminals that don't support focus reporting; +// consumers treat 'unknown' identically to 'focused' (no throttling). +// Subscribers are notified synchronously when focus changes, used by +// TerminalFocusProvider to avoid polling. +export type TerminalFocusState = 'focused' | 'blurred' | 'unknown' + +let focusState: TerminalFocusState = 'unknown' +const resolvers: Set<() => void> = new Set() +const subscribers: Set<() => void> = new Set() + +export function setTerminalFocused(v: boolean): void { + focusState = v ? 'focused' : 'blurred' + + // Notify useSyncExternalStore subscribers + for (const cb of subscribers) { + cb() + } + + if (!v) { + for (const resolve of resolvers) { + resolve() + } + + resolvers.clear() + } +} + +export function getTerminalFocused(): boolean { + return focusState !== 'blurred' +} + +export function getTerminalFocusState(): TerminalFocusState { + return focusState +} + +// For useSyncExternalStore +export function subscribeTerminalFocus(cb: () => void): () => void { + subscribers.add(cb) + + return () => { + subscribers.delete(cb) + } +} + +export function resetTerminalFocusState(): void { + focusState = 'unknown' + + for (const cb of subscribers) { + cb() + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal-querier.ts b/ui-tui/packages/hermes-ink/src/ink/terminal-querier.ts new file mode 100644 index 0000000000..80b1b80ef6 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/terminal-querier.ts @@ -0,0 +1,222 @@ +/** + * Query the terminal and await responses without timeouts. + * + * Terminal queries (DECRQM, DA1, OSC 11, etc.) share the stdin stream + * with keyboard input. Response sequences are syntactically + * distinguishable from key events, so the input parser recognizes them + * and dispatches them here. + * + * To avoid timeouts, each query batch is terminated by a DA1 sentinel + * (CSI c) — every terminal since VT100 responds to DA1, and terminals + * answer queries in order. So: if your query's response arrives before + * DA1's, the terminal supports it; if DA1 arrives first, it doesn't. + * + * Usage: + * const [sync, grapheme] = await Promise.all([ + * querier.send(decrqm(2026)), + * querier.send(decrqm(2027)), + * querier.flush(), + * ]) + * // sync and grapheme are DECRPM responses or undefined if unsupported + */ + +import type { TerminalResponse } from './parse-keypress.js' +import { csi } from './termio/csi.js' +import { osc } from './termio/osc.js' + +/** A terminal query: an outbound request sequence paired with a matcher + * that recognizes the expected inbound response. Built by `decrqm()`, + * `oscColor()`, `kittyKeyboard()`, etc. */ +export type TerminalQuery = { + /** Escape sequence to write to stdout */ + request: string + /** Recognizes the expected response in the inbound stream */ + match: (r: TerminalResponse) => r is T +} + +type DecrpmResponse = Extract +type Da1Response = Extract +type Da2Response = Extract +type KittyResponse = Extract +type CursorPosResponse = Extract +type OscResponse = Extract +type XtversionResponse = Extract + +// -- Query builders -- + +/** DECRQM: request DEC private mode status (CSI ? mode $ p). + * Terminal replies with DECRPM (CSI ? mode ; status $ y) or ignores. */ +export function decrqm(mode: number): TerminalQuery { + return { + request: csi(`?${mode}$p`), + match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode + } +} + +/** Primary Device Attributes query (CSI c). Every terminal answers this — + * used internally by flush() as a universal sentinel. Call directly if + * you want the DA1 params. */ +export function da1(): TerminalQuery { + return { + request: csi('c'), + match: (r): r is Da1Response => r.type === 'da1' + } +} + +/** Secondary Device Attributes query (CSI > c). Returns terminal version. */ +export function da2(): TerminalQuery { + return { + request: csi('>c'), + match: (r): r is Da2Response => r.type === 'da2' + } +} + +/** Query current Kitty keyboard protocol flags (CSI ? u). + * Terminal replies with CSI ? flags u or ignores. */ +export function kittyKeyboard(): TerminalQuery { + return { + request: csi('?u'), + match: (r): r is KittyResponse => r.type === 'kittyKeyboard' + } +} + +/** DECXCPR: request cursor position with DEC-private marker (CSI ? 6 n). + * Terminal replies with CSI ? row ; col R. The `?` marker is critical — + * the plain DSR form (CSI 6 n → CSI row;col R) is ambiguous with + * modified F3 keys (Shift+F3 = CSI 1;2 R, etc.). */ +export function cursorPosition(): TerminalQuery { + return { + request: csi('?6n'), + match: (r): r is CursorPosResponse => r.type === 'cursorPosition' + } +} + +/** OSC dynamic color query (e.g. OSC 11 for bg color, OSC 10 for fg). + * The `?` data slot asks the terminal to reply with the current value. */ +export function oscColor(code: number): TerminalQuery { + return { + request: osc(code, '?'), + match: (r): r is OscResponse => r.type === 'osc' && r.code === code + } +} + +/** XTVERSION: request terminal name/version (CSI > 0 q). + * Terminal replies with DCS > | name ST (e.g. "xterm.js(5.5.0)") or ignores. + * This survives SSH — the query goes through the pty, not the environment, + * so it identifies the *client* terminal even when TERM_PROGRAM isn't + * forwarded. Used to detect xterm.js for wheel-scroll compensation. */ +export function xtversion(): TerminalQuery { + return { + request: csi('>0q'), + match: (r): r is XtversionResponse => r.type === 'xtversion' + } +} + +// -- Querier -- + +/** Sentinel request sequence (DA1). Kept internal; flush() writes it. */ +const SENTINEL = csi('c') + +type Pending = + | { + kind: 'query' + match: (r: TerminalResponse) => boolean + resolve: (r: TerminalResponse | undefined) => void + } + | { kind: 'sentinel'; resolve: () => void } + +export class TerminalQuerier { + /** + * Interleaved queue of queries and sentinels in send order. Terminals + * respond in order, so each flush() barrier only drains queries queued + * before it — concurrent batches from independent callers stay isolated. + */ + private queue: Pending[] = [] + + constructor(private stdout: NodeJS.WriteStream) {} + + /** + * Send a query and wait for its response. + * + * Resolves with the response when `query.match` matches an incoming + * TerminalResponse, or with `undefined` when a flush() sentinel arrives + * before any matching response (meaning the terminal ignored the query). + * + * Never rejects; never times out on its own. If you never call flush() + * and the terminal doesn't respond, the promise remains pending. + */ + send(query: TerminalQuery): Promise { + return new Promise(resolve => { + this.queue.push({ + kind: 'query', + match: query.match, + resolve: r => resolve(r as T | undefined) + }) + this.stdout.write(query.request) + }) + } + + /** + * Send the DA1 sentinel. Resolves when DA1's response arrives. + * + * As a side effect, all queries still pending when DA1 arrives are + * resolved with `undefined` (terminal didn't respond → doesn't support + * the query). This is the barrier that makes send() timeout-free. + * + * Safe to call with no pending queries — still waits for a round-trip. + */ + flush(): Promise { + return new Promise(resolve => { + this.queue.push({ kind: 'sentinel', resolve }) + this.stdout.write(SENTINEL) + }) + } + + /** + * Dispatch a response parsed from stdin. Called by App.tsx's + * processKeysInBatch for every `kind: 'response'` item. + * + * Matching strategy: + * - First, try to match a pending query (FIFO, first match wins). + * This lets callers send(da1()) explicitly if they want the DA1 + * params — a separate DA1 write means the terminal sends TWO DA1 + * responses. The first matches the explicit query; the second + * (unmatched) fires the sentinel. + * - Otherwise, if this is a DA1, fire the FIRST pending sentinel: + * resolve any queries queued before that sentinel with undefined + * (the terminal answered DA1 without answering them → unsupported) + * and signal its flush() completion. Only draining up to the first + * sentinel keeps later batches intact when multiple callers have + * concurrent queries in flight. + * - Unsolicited responses (no match, no sentinel) are silently dropped. + */ + onResponse(r: TerminalResponse): void { + const idx = this.queue.findIndex(p => p.kind === 'query' && p.match(r)) + + if (idx !== -1) { + const [q] = this.queue.splice(idx, 1) + + if (q?.kind === 'query') { + q.resolve(r) + } + + return + } + + if (r.type === 'da1') { + const s = this.queue.findIndex(p => p.kind === 'sentinel') + + if (s === -1) { + return + } + + for (const p of this.queue.splice(0, s + 1)) { + if (p.kind === 'query') { + p.resolve(undefined) + } else { + p.resolve() + } + } + } + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/terminal.ts b/ui-tui/packages/hermes-ink/src/ink/terminal.ts new file mode 100644 index 0000000000..8ac7d62b69 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/terminal.ts @@ -0,0 +1,282 @@ +import type { Writable } from 'stream' + +import { coerce } from 'semver' + +import { env } from '../utils/env.js' +import { gte } from '../utils/semver.js' + +import { getClearTerminalSequence } from './clearTerminal.js' +import type { Diff } from './frame.js' +import { cursorMove, cursorTo, eraseLines } from './termio/csi.js' +import { BSU, ESU, HIDE_CURSOR, SHOW_CURSOR } from './termio/dec.js' +import { link } from './termio/osc.js' + +export type Progress = { + state: 'running' | 'completed' | 'error' | 'indeterminate' + percentage?: number +} + +/** + * Checks if the terminal supports OSC 9;4 progress reporting. + * Supported terminals: + * - ConEmu (Windows) - all versions + * - Ghostty 1.2.0+ + * - iTerm2 3.6.6+ + * + * Note: Windows Terminal interprets OSC 9;4 as notifications, not progress. + */ +export function isProgressReportingAvailable(): boolean { + // Only available if we have a TTY (not piped) + if (!process.stdout.isTTY) { + return false + } + + // Explicitly exclude Windows Terminal, which interprets OSC 9;4 as + // notifications rather than progress indicators + if (process.env.WT_SESSION) { + return false + } + + // ConEmu supports OSC 9;4 for progress (all versions) + if (process.env.ConEmuANSI || process.env.ConEmuPID || process.env.ConEmuTask) { + return true + } + + const version = coerce(process.env.TERM_PROGRAM_VERSION) + + if (!version) { + return false + } + + // Ghostty 1.2.0+ supports OSC 9;4 for progress + // https://ghostty.org/docs/install/release-notes/1-2-0 + if (process.env.TERM_PROGRAM === 'ghostty') { + return gte(version.version, '1.2.0') + } + + // iTerm2 3.6.6+ supports OSC 9;4 for progress + // https://iterm2.com/downloads.html + if (process.env.TERM_PROGRAM === 'iTerm.app') { + return gte(version.version, '3.6.6') + } + + return false +} + +/** + * Checks if the terminal supports DEC mode 2026 (synchronized output). + * When supported, BSU/ESU sequences prevent visible flicker during redraws. + */ +export function isSynchronizedOutputSupported(): boolean { + // tmux parses and proxies every byte but doesn't implement DEC 2026. + // BSU/ESU pass through to the outer terminal but tmux has already + // broken atomicity by chunking. Skip to save 16 bytes/frame + parser work. + if (process.env.TMUX) { + return false + } + + const termProgram = process.env.TERM_PROGRAM + const term = process.env.TERM + + // Modern terminals with known DEC 2026 support + if ( + termProgram === 'iTerm.app' || + termProgram === 'WezTerm' || + termProgram === 'WarpTerminal' || + termProgram === 'ghostty' || + termProgram === 'contour' || + termProgram === 'vscode' || + termProgram === 'alacritty' + ) { + return true + } + + // kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID + if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) { + return true + } + + // Ghostty may set TERM=xterm-ghostty without TERM_PROGRAM + if (term === 'xterm-ghostty') { + return true + } + + // foot sets TERM=foot or TERM=foot-extra + if (term?.startsWith('foot')) { + return true + } + + // Alacritty may set TERM containing 'alacritty' + if (term?.includes('alacritty')) { + return true + } + + // Zed uses the alacritty_terminal crate which supports DEC 2026 + if (process.env.ZED_TERM) { + return true + } + + // Windows Terminal + if (process.env.WT_SESSION) { + return true + } + + // VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68 + const vteVersion = process.env.VTE_VERSION + + if (vteVersion) { + const version = parseInt(vteVersion, 10) + + if (version >= 6800) { + return true + } + } + + return false +} + +// -- XTVERSION-detected terminal name (populated async at startup) -- +// +// TERM_PROGRAM is not forwarded over SSH by default, so env-based detection +// fails when claude runs remotely inside a VS Code integrated terminal. +// XTVERSION (CSI > 0 q → DCS > | name ST) goes through the pty — the query +// reaches the *client* terminal and the reply comes back through stdin. +// App.tsx fires the query when raw mode enables; setXtversionName() is called +// from the response handler. Readers should treat undefined as "not yet known" +// and fall back to env-var detection. + +let xtversionName: string | undefined + +/** Record the XTVERSION response. Called once from App.tsx when the reply + * arrives on stdin. No-op if already set (defend against re-probe). */ +export function setXtversionName(name: string): void { + if (xtversionName === undefined) { + xtversionName = name + } +} + +/** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf + * integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but + * not forwarded over SSH) with the XTVERSION probe result (async, survives + * SSH — query/reply goes through the pty). Early calls may miss the probe + * reply — call lazily (e.g. in an event handler) if SSH detection matters. */ +export function isXtermJs(): boolean { + if (process.env.TERM_PROGRAM === 'vscode') { + return true + } + + return xtversionName?.startsWith('xterm.js') ?? false +} + +// Terminals known to correctly implement the Kitty keyboard protocol +// (CSI >1u) and/or xterm modifyOtherKeys (CSI >4;2m) for ctrl+shift+ +// disambiguation. We previously enabled unconditionally (#23350), assuming +// terminals silently ignore unknown CSI — but some terminals honor the enable +// and emit codepoints our input parser doesn't handle (notably over SSH and +// in xterm.js-based terminals like VS Code). tmux is allowlisted because it +// accepts modifyOtherKeys and doesn't forward the kitty sequence to the outer +// terminal. +const EXTENDED_KEYS_TERMINALS = ['iTerm.app', 'kitty', 'WezTerm', 'ghostty', 'tmux', 'windows-terminal'] + +/** True if this terminal correctly handles extended key reporting + * (Kitty keyboard protocol + xterm modifyOtherKeys). */ +export function supportsExtendedKeys(): boolean { + return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '') +} + +/** True if the terminal scrolls the viewport when it receives cursor-up + * sequences that reach above the visible area. On Windows, conhost's + * SetConsoleCursorPosition follows the cursor into scrollback + * (microsoft/terminal#14774), yanking users to the top of their buffer + * mid-stream. WT_SESSION catches WSL-in-Windows-Terminal where platform + * is linux but output still routes through conhost. */ +export function hasCursorUpViewportYankBug(): boolean { + return process.platform === 'win32' || !!process.env.WT_SESSION +} + +// Computed once at module load — terminal capabilities don't change mid-session. +// Exported so callers can pass a sync-skip hint gated to specific modes. +export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported() + +export type Terminal = { + stdout: Writable + stderr: Writable +} + +export function writeDiffToTerminal(terminal: Terminal, diff: Diff, skipSyncMarkers = false): void { + // No output if there are no patches + if (diff.length === 0) { + return + } + + // BSU/ESU wrapping is opt-out to keep main-screen behavior unchanged. + // Callers pass skipSyncMarkers=true when the terminal doesn't support + // DEC 2026 (e.g. tmux) AND the cost matters (high-frequency alt-screen). + const useSync = !skipSyncMarkers + + // Buffer all writes into a single string to avoid multiple write calls + let buffer = useSync ? BSU : '' + + for (const patch of diff) { + switch (patch.type) { + case 'stdout': + buffer += patch.content + + break + + case 'clear': + if (patch.count > 0) { + buffer += eraseLines(patch.count) + } + + break + + case 'clearTerminal': + buffer += getClearTerminalSequence() + + break + + case 'cursorHide': + buffer += HIDE_CURSOR + + break + + case 'cursorShow': + buffer += SHOW_CURSOR + + break + + case 'cursorMove': + buffer += cursorMove(patch.x, patch.y) + + break + + case 'cursorTo': + buffer += cursorTo(patch.col) + + break + + case 'carriageReturn': + buffer += '\r' + + break + + case 'hyperlink': + buffer += link(patch.uri) + + break + + case 'styleStr': + buffer += patch.str + + break + } + } + + // Add synchronized update end and flush buffer + if (useSync) { + buffer += ESU + } + + terminal.stdout.write(buffer) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/termio.ts b/ui-tui/packages/hermes-ink/src/ink/termio.ts new file mode 100644 index 0000000000..e14db928cb --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio.ts @@ -0,0 +1,42 @@ +/** + * ANSI Parser Module + * + * A semantic ANSI escape sequence parser inspired by ghostty, tmux, and iTerm2. + * + * Key features: + * - Semantic output: produces structured actions, not string tokens + * - Streaming: can parse input incrementally via Parser class + * - Style tracking: maintains text style state across parse calls + * - Comprehensive: supports SGR, CSI, OSC, ESC sequences + * + * Usage: + * + * ```typescript + * import { Parser } from './termio.js' + * + * const parser = new Parser() + * const actions = parser.feed('\x1b[31mred\x1b[0m') + * // => [{ type: 'text', graphemes: [...], style: { fg: { type: 'named', name: 'red' }, ... } }] + * ``` + */ + +// Parser +export { Parser } from './termio/parser.js' +// Types +export type { + Action, + Color, + CursorAction, + CursorDirection, + EraseAction, + Grapheme, + LinkAction, + ModeAction, + NamedColor, + ScrollAction, + TextSegment, + TextStyle, + TitleAction, + UnderlineStyle +} from './termio/types.js' +export { colorsEqual, defaultStyle, stylesEqual } from './termio/types.js' diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/ansi.ts b/ui-tui/packages/hermes-ink/src/ink/termio/ansi.ts new file mode 100644 index 0000000000..138cfef29c --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/ansi.ts @@ -0,0 +1,75 @@ +/** + * ANSI Control Characters and Escape Sequence Introducers + * + * Based on ECMA-48 / ANSI X3.64 standards. + */ + +/** + * C0 (7-bit) control characters + */ +export const C0 = { + NUL: 0x00, + SOH: 0x01, + STX: 0x02, + ETX: 0x03, + EOT: 0x04, + ENQ: 0x05, + ACK: 0x06, + BEL: 0x07, + BS: 0x08, + HT: 0x09, + LF: 0x0a, + VT: 0x0b, + FF: 0x0c, + CR: 0x0d, + SO: 0x0e, + SI: 0x0f, + DLE: 0x10, + DC1: 0x11, + DC2: 0x12, + DC3: 0x13, + DC4: 0x14, + NAK: 0x15, + SYN: 0x16, + ETB: 0x17, + CAN: 0x18, + EM: 0x19, + SUB: 0x1a, + ESC: 0x1b, + FS: 0x1c, + GS: 0x1d, + RS: 0x1e, + US: 0x1f, + DEL: 0x7f +} as const + +// String constants for output generation +export const ESC = '\x1b' +export const BEL = '\x07' +export const SEP = ';' + +/** + * Escape sequence type introducers (byte after ESC) + */ +export const ESC_TYPE = { + CSI: 0x5b, // [ - Control Sequence Introducer + OSC: 0x5d, // ] - Operating System Command + DCS: 0x50, // P - Device Control String + APC: 0x5f, // _ - Application Program Command + PM: 0x5e, // ^ - Privacy Message + SOS: 0x58, // X - Start of String + ST: 0x5c // \ - String Terminator +} as const + +/** Check if a byte is a C0 control character */ +export function isC0(byte: number): boolean { + return byte < 0x20 || byte === 0x7f +} + +/** + * Check if a byte is an ESC sequence final byte (0-9, :, ;, <, =, >, ?, @ through ~) + * ESC sequences have a wider final byte range than CSI + */ +export function isEscFinal(byte: number): boolean { + return byte >= 0x30 && byte <= 0x7e +} diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/csi.ts b/ui-tui/packages/hermes-ink/src/ink/termio/csi.ts new file mode 100644 index 0000000000..5d4fbe7ef7 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/csi.ts @@ -0,0 +1,334 @@ +/** + * CSI (Control Sequence Introducer) Types + * + * Enums and types for CSI command parameters. + */ + +import { ESC, ESC_TYPE, SEP } from './ansi.js' + +export const CSI_PREFIX = ESC + String.fromCharCode(ESC_TYPE.CSI) + +/** + * CSI parameter byte ranges + */ +export const CSI_RANGE = { + PARAM_START: 0x30, + PARAM_END: 0x3f, + INTERMEDIATE_START: 0x20, + INTERMEDIATE_END: 0x2f, + FINAL_START: 0x40, + FINAL_END: 0x7e +} as const + +/** Check if a byte is a CSI parameter byte */ +export function isCSIParam(byte: number): boolean { + return byte >= CSI_RANGE.PARAM_START && byte <= CSI_RANGE.PARAM_END +} + +/** Check if a byte is a CSI intermediate byte */ +export function isCSIIntermediate(byte: number): boolean { + return byte >= CSI_RANGE.INTERMEDIATE_START && byte <= CSI_RANGE.INTERMEDIATE_END +} + +/** Check if a byte is a CSI final byte (@ through ~) */ +export function isCSIFinal(byte: number): boolean { + return byte >= CSI_RANGE.FINAL_START && byte <= CSI_RANGE.FINAL_END +} + +/** + * Generate a CSI sequence: ESC [ p1;p2;...;pN final + * Single arg: treated as raw body + * Multiple args: last is final byte, rest are params joined by ; + */ +export function csi(...args: (string | number)[]): string { + if (args.length === 0) { + return CSI_PREFIX + } + + if (args.length === 1) { + return `${CSI_PREFIX}${args[0]}` + } + + const params = args.slice(0, -1) + const final = args[args.length - 1] + + return `${CSI_PREFIX}${params.join(SEP)}${final}` +} + +/** + * CSI final bytes - the command identifier + */ +export const CSI = { + // Cursor movement + CUU: 0x41, // A - Cursor Up + CUD: 0x42, // B - Cursor Down + CUF: 0x43, // C - Cursor Forward + CUB: 0x44, // D - Cursor Back + CNL: 0x45, // E - Cursor Next Line + CPL: 0x46, // F - Cursor Previous Line + CHA: 0x47, // G - Cursor Horizontal Absolute + CUP: 0x48, // H - Cursor Position + CHT: 0x49, // I - Cursor Horizontal Tab + VPA: 0x64, // d - Vertical Position Absolute + HVP: 0x66, // f - Horizontal Vertical Position + + // Erase + ED: 0x4a, // J - Erase in Display + EL: 0x4b, // K - Erase in Line + ECH: 0x58, // X - Erase Character + + // Insert/Delete + IL: 0x4c, // L - Insert Lines + DL: 0x4d, // M - Delete Lines + ICH: 0x40, // @ - Insert Characters + DCH: 0x50, // P - Delete Characters + + // Scroll + SU: 0x53, // S - Scroll Up + SD: 0x54, // T - Scroll Down + + // Modes + SM: 0x68, // h - Set Mode + RM: 0x6c, // l - Reset Mode + + // SGR + SGR: 0x6d, // m - Select Graphic Rendition + + // Other + DSR: 0x6e, // n - Device Status Report + DECSCUSR: 0x71, // q - Set Cursor Style (with space intermediate) + DECSTBM: 0x72, // r - Set Top and Bottom Margins + SCOSC: 0x73, // s - Save Cursor Position + SCORC: 0x75, // u - Restore Cursor Position + CBT: 0x5a // Z - Cursor Backward Tabulation +} as const + +/** + * Erase in Display regions (ED command parameter) + */ +export const ERASE_DISPLAY = ['toEnd', 'toStart', 'all', 'scrollback'] as const + +/** + * Erase in Line regions (EL command parameter) + */ +export const ERASE_LINE_REGION = ['toEnd', 'toStart', 'all'] as const + +/** + * Cursor styles (DECSCUSR) + */ +export type CursorStyle = 'block' | 'underline' | 'bar' + +export const CURSOR_STYLES: Array<{ style: CursorStyle; blinking: boolean }> = [ + { style: 'block', blinking: true }, // 0 - default + { style: 'block', blinking: true }, // 1 + { style: 'block', blinking: false }, // 2 + { style: 'underline', blinking: true }, // 3 + { style: 'underline', blinking: false }, // 4 + { style: 'bar', blinking: true }, // 5 + { style: 'bar', blinking: false } // 6 +] + +// Cursor movement generators + +/** Move cursor up n lines (CSI n A) */ +export function cursorUp(n = 1): string { + return n === 0 ? '' : csi(n, 'A') +} + +/** Move cursor down n lines (CSI n B) */ +export function cursorDown(n = 1): string { + return n === 0 ? '' : csi(n, 'B') +} + +/** Move cursor forward n columns (CSI n C) */ +export function cursorForward(n = 1): string { + return n === 0 ? '' : csi(n, 'C') +} + +/** Move cursor back n columns (CSI n D) */ +export function cursorBack(n = 1): string { + return n === 0 ? '' : csi(n, 'D') +} + +/** Move cursor to column n (1-indexed) (CSI n G) */ +export function cursorTo(col: number): string { + return csi(col, 'G') +} + +/** Move cursor to column 1 (CSI G) */ +export const CURSOR_LEFT = csi('G') + +/** Move cursor to row, col (1-indexed) (CSI row ; col H) */ +export function cursorPosition(row: number, col: number): string { + return csi(row, col, 'H') +} + +/** Move cursor to home position (CSI H) */ +export const CURSOR_HOME = csi('H') + +/** + * Move cursor relative to current position + * Positive x = right, negative x = left + * Positive y = down, negative y = up + */ +export function cursorMove(x: number, y: number): string { + let result = '' + + // Horizontal first (matches ansi-escapes behavior) + if (x < 0) { + result += cursorBack(-x) + } else if (x > 0) { + result += cursorForward(x) + } + + // Then vertical + if (y < 0) { + result += cursorUp(-y) + } else if (y > 0) { + result += cursorDown(y) + } + + return result +} + +// Save/restore cursor position + +/** Save cursor position (CSI s) */ +export const CURSOR_SAVE = csi('s') + +/** Restore cursor position (CSI u) */ +export const CURSOR_RESTORE = csi('u') + +// Erase generators + +/** Erase from cursor to end of line (CSI K) */ +export function eraseToEndOfLine(): string { + return csi('K') +} + +/** Erase from cursor to start of line (CSI 1 K) */ +export function eraseToStartOfLine(): string { + return csi(1, 'K') +} + +/** Erase entire line (CSI 2 K) */ +export function eraseLine(): string { + return csi(2, 'K') +} + +/** Erase entire line - constant form */ +export const ERASE_LINE = csi(2, 'K') + +/** Erase from cursor to end of screen (CSI J) */ +export function eraseToEndOfScreen(): string { + return csi('J') +} + +/** Erase from cursor to start of screen (CSI 1 J) */ +export function eraseToStartOfScreen(): string { + return csi(1, 'J') +} + +/** Erase entire screen (CSI 2 J) */ +export function eraseScreen(): string { + return csi(2, 'J') +} + +/** Erase entire screen - constant form */ +export const ERASE_SCREEN = csi(2, 'J') + +/** Erase scrollback buffer (CSI 3 J) */ +export const ERASE_SCROLLBACK = csi(3, 'J') + +/** + * Erase n lines starting from cursor line, moving cursor up + * This erases each line and moves up, ending at column 1 + */ +export function eraseLines(n: number): string { + if (n <= 0) { + return '' + } + + let result = '' + + for (let i = 0; i < n; i++) { + result += ERASE_LINE + + if (i < n - 1) { + result += cursorUp(1) + } + } + + result += CURSOR_LEFT + + return result +} + +// Scroll + +/** Scroll up n lines (CSI n S) */ +export function scrollUp(n = 1): string { + return n === 0 ? '' : csi(n, 'S') +} + +/** Scroll down n lines (CSI n T) */ +export function scrollDown(n = 1): string { + return n === 0 ? '' : csi(n, 'T') +} + +/** Set scroll region (DECSTBM, CSI top;bottom r). 1-indexed, inclusive. */ +export function setScrollRegion(top: number, bottom: number): string { + return csi(top, bottom, 'r') +} + +/** Reset scroll region to full screen (DECSTBM, CSI r). Homes the cursor. */ +export const RESET_SCROLL_REGION = csi('r') + +// Bracketed paste markers (input from terminal, not output) +// These are sent by the terminal to delimit pasted content when +// bracketed paste mode is enabled (via DEC mode 2004) + +/** Sent by terminal before pasted content (CSI 200 ~) */ +export const PASTE_START = csi('200~') + +/** Sent by terminal after pasted content (CSI 201 ~) */ +export const PASTE_END = csi('201~') + +// Focus event markers (input from terminal, not output) +// These are sent by the terminal when focus changes while +// focus events mode is enabled (via DEC mode 1004) + +/** Sent by terminal when it gains focus (CSI I) */ +export const FOCUS_IN = csi('I') + +/** Sent by terminal when it loses focus (CSI O) */ +export const FOCUS_OUT = csi('O') + +// Kitty keyboard protocol (CSI u) +// Enables enhanced key reporting with modifier information +// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + +/** + * Enable Kitty keyboard protocol with basic modifier reporting + * CSI > 1 u - pushes mode with flags=1 (disambiguate escape codes) + * This makes Shift+Enter send CSI 13;2 u instead of just CR + */ +export const ENABLE_KITTY_KEYBOARD = csi('>1u') + +/** + * Disable Kitty keyboard protocol + * CSI < u - pops the keyboard mode stack + */ +export const DISABLE_KITTY_KEYBOARD = csi('4;2m') + +/** + * Disable xterm modifyOtherKeys (reset to default). + */ +export const DISABLE_MODIFY_OTHER_KEYS = csi('>4m') diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/dec.ts b/ui-tui/packages/hermes-ink/src/ink/termio/dec.ts new file mode 100644 index 0000000000..4548b923ff --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/dec.ts @@ -0,0 +1,54 @@ +/** + * DEC (Digital Equipment Corporation) Private Mode Sequences + * + * DEC private modes use CSI ? N h (set) and CSI ? N l (reset) format. + * These are terminal-specific extensions to the ANSI standard. + */ + +import { csi } from './csi.js' + +/** + * DEC private mode numbers + */ +export const DEC = { + CURSOR_VISIBLE: 25, + ALT_SCREEN: 47, + ALT_SCREEN_CLEAR: 1049, + MOUSE_NORMAL: 1000, + MOUSE_BUTTON: 1002, + MOUSE_ANY: 1003, + MOUSE_SGR: 1006, + FOCUS_EVENTS: 1004, + BRACKETED_PASTE: 2004, + SYNCHRONIZED_UPDATE: 2026 +} as const + +/** Generate CSI ? N h sequence (set mode) */ +export function decset(mode: number): string { + return csi(`?${mode}h`) +} + +/** Generate CSI ? N l sequence (reset mode) */ +export function decreset(mode: number): string { + return csi(`?${mode}l`) +} + +// Pre-generated sequences for common modes +export const BSU = decset(DEC.SYNCHRONIZED_UPDATE) +export const ESU = decreset(DEC.SYNCHRONIZED_UPDATE) +export const EBP = decset(DEC.BRACKETED_PASTE) +export const DBP = decreset(DEC.BRACKETED_PASTE) +export const EFE = decset(DEC.FOCUS_EVENTS) +export const DFE = decreset(DEC.FOCUS_EVENTS) +export const SHOW_CURSOR = decset(DEC.CURSOR_VISIBLE) +export const HIDE_CURSOR = decreset(DEC.CURSOR_VISIBLE) +export const ENTER_ALT_SCREEN = decset(DEC.ALT_SCREEN_CLEAR) +export const EXIT_ALT_SCREEN = decreset(DEC.ALT_SCREEN_CLEAR) +// Mouse tracking: 1000 reports button press/release/wheel, 1002 adds drag +// events (button-motion), 1003 adds all-motion (no button held — for +// hover), 1006 uses SGR format (CSI < btn;col;row M/m) instead of legacy +// X10 bytes. Combined: wheel + click/drag for selection + hover. +export const ENABLE_MOUSE_TRACKING = + decset(DEC.MOUSE_NORMAL) + decset(DEC.MOUSE_BUTTON) + decset(DEC.MOUSE_ANY) + decset(DEC.MOUSE_SGR) +export const DISABLE_MOUSE_TRACKING = + decreset(DEC.MOUSE_SGR) + decreset(DEC.MOUSE_ANY) + decreset(DEC.MOUSE_BUTTON) + decreset(DEC.MOUSE_NORMAL) diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/esc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/esc.ts new file mode 100644 index 0000000000..4e38d7d035 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/esc.ts @@ -0,0 +1,69 @@ +/** + * ESC Sequence Parser + * + * Handles simple escape sequences: ESC + one or two characters + */ + +import type { Action } from './types.js' + +/** + * Parse a simple ESC sequence + * + * @param chars - Characters after ESC (not including ESC itself) + */ +export function parseEsc(chars: string): Action | null { + if (chars.length === 0) { + return null + } + + const first = chars[0]! + + // Full reset (RIS) + if (first === 'c') { + return { type: 'reset' } + } + + // Cursor save (DECSC) + if (first === '7') { + return { type: 'cursor', action: { type: 'save' } } + } + + // Cursor restore (DECRC) + if (first === '8') { + return { type: 'cursor', action: { type: 'restore' } } + } + + // Index - move cursor down (IND) + if (first === 'D') { + return { + type: 'cursor', + action: { type: 'move', direction: 'down', count: 1 } + } + } + + // Reverse index - move cursor up (RI) + if (first === 'M') { + return { + type: 'cursor', + action: { type: 'move', direction: 'up', count: 1 } + } + } + + // Next line (NEL) + if (first === 'E') { + return { type: 'cursor', action: { type: 'nextLine', count: 1 } } + } + + // Horizontal tab set (HTS) + if (first === 'H') { + return null // Tab stop, not commonly needed + } + + // Charset selection (ESC ( X, ESC ) X, etc.) - silently ignore + if ('()'.includes(first) && chars.length >= 2) { + return null + } + + // Unknown + return { type: 'unknown', sequence: `\x1b${chars}` } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts new file mode 100644 index 0000000000..49f222395a --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/osc.ts @@ -0,0 +1,554 @@ +/** + * OSC (Operating System Command) Types and Parser + */ + +import { Buffer } from 'buffer' + +import { env } from '../../utils/env.js' +import { execFileNoThrow } from '../../utils/execFileNoThrow.js' + +import { BEL, ESC, ESC_TYPE, SEP } from './ansi.js' +import type { Action, Color, TabStatusAction } from './types.js' + +export const OSC_PREFIX = ESC + String.fromCharCode(ESC_TYPE.OSC) + +/** String Terminator (ESC \) - alternative to BEL for terminating OSC */ +export const ST = ESC + '\\' + +/** Generate an OSC sequence: ESC ] p1;p2;...;pN + * Uses ST terminator for Kitty (avoids beeps), BEL for others */ +export function osc(...parts: (string | number)[]): string { + const terminator = env.terminal === 'kitty' ? ST : BEL + + return `${OSC_PREFIX}${parts.join(SEP)}${terminator}` +} + +/** + * Wrap an escape sequence for terminal multiplexer passthrough. + * tmux and GNU screen intercept escape sequences; DCS passthrough + * tunnels them to the outer terminal unmodified. + * + * tmux 3.3+ gates this behind `allow-passthrough` (default off). When off, + * tmux silently drops the whole DCS — no junk, no worse than unwrapped OSC. + * Users who want passthrough set it in their .tmux.conf; we don't mutate it. + * + * Do NOT wrap BEL: raw \x07 triggers tmux's bell-action (window flag); + * wrapped \x07 is opaque DCS payload and tmux never sees the bell. + */ +export function wrapForMultiplexer(sequence: string): string { + if (process.env['TMUX']) { + const escaped = sequence.replaceAll('\x1b', '\x1b\x1b') + + return `\x1bPtmux;${escaped}\x1b\\` + } + + if (process.env['STY']) { + return `\x1bP${sequence}\x1b\\` + } + + return sequence +} + +/** + * Which path setClipboard() will take, based on env state. Synchronous so + * callers can show an honest toast without awaiting the copy itself. + * + * - 'native': pbcopy (or equivalent) will run — high-confidence system + * clipboard write. tmux buffer may also be loaded as a bonus. + * - 'tmux-buffer': tmux load-buffer will run, but no native tool — paste + * with prefix+] works. System clipboard depends on tmux's set-clipboard + * option + outer terminal OSC 52 support; can't know from here. + * - 'osc52': only the raw OSC 52 sequence will be written to stdout. + * Best-effort; iTerm2 disables OSC 52 by default. + * + * pbcopy gating uses SSH_CONNECTION specifically, not SSH_TTY — tmux panes + * inherit SSH_TTY forever even after local reattach, but SSH_CONNECTION is + * in tmux's default update-environment set and gets cleared. + */ +export type ClipboardPath = 'native' | 'tmux-buffer' | 'osc52' + +export function getClipboardPath(): ClipboardPath { + const nativeAvailable = process.platform === 'darwin' && !process.env['SSH_CONNECTION'] + + if (nativeAvailable) { + return 'native' + } + + if (process.env['TMUX']) { + return 'tmux-buffer' + } + + return 'osc52' +} + +/** + * Wrap a payload in tmux's DCS passthrough: ESC P tmux ; ESC \ + * tmux forwards the payload to the outer terminal, bypassing its own parser. + * Inner ESCs must be doubled. Requires `set -g allow-passthrough on` in + * ~/.tmux.conf; without it, tmux silently drops the whole DCS (no regression). + */ +function tmuxPassthrough(payload: string): string { + return `${ESC}Ptmux;${payload.replaceAll(ESC, ESC + ESC)}${ST}` +} + +/** + * Load text into tmux's paste buffer via `tmux load-buffer`. + * -w (tmux 3.2+) propagates to the outer terminal's clipboard via tmux's + * own OSC 52 emission. -w is dropped for iTerm2: tmux's OSC 52 emission + * crashes the iTerm2 session over SSH. + * + * Returns true if the buffer was loaded successfully. + */ +export async function tmuxLoadBuffer(text: string): Promise { + if (!process.env['TMUX']) { + return false + } + + const args = process.env['LC_TERMINAL'] === 'iTerm2' ? ['load-buffer', '-'] : ['load-buffer', '-w', '-'] + + const { code } = await execFileNoThrow('tmux', args, { + input: text, + useCwd: false, + timeout: 2000 + }) + + return code === 0 +} + +/** + * OSC 52 clipboard write: ESC ] 52 ; c ; BEL/ST + * 'c' selects the clipboard (vs 'p' for primary selection on X11). + * + * When inside tmux ($TMUX set), `tmux load-buffer -w -` is the primary + * path. tmux's buffer is always reachable — works over SSH, survives + * detach/reattach, immune to stale env vars. The -w flag (tmux 3.2+) tells + * tmux to also propagate to the outer terminal via its own OSC 52 path, + * which tmux wraps correctly for the attached client. On older tmux, -w is + * ignored and the buffer is still loaded. -w is dropped for iTerm2 (#22432) + * because tmux's own OSC 52 emission (empty selection param: ESC]52;;b64) + * crashes iTerm2 over SSH. + * + * After load-buffer succeeds, we ALSO return a DCS-passthrough-wrapped + * OSC 52 for the caller to write to stdout. Our sequence uses explicit `c` + * (not tmux's crashy empty-param variant), so it sidesteps the #22432 path. + * With `allow-passthrough on` + an OSC-52-capable outer terminal, selection + * reaches the system clipboard; with either off, tmux silently drops the + * DCS and prefix+] still works. See Greg Smith's "free pony" in + * https://anthropic.slack.com/archives/C07VBSHV7EV/p1773177228548119. + * + * If load-buffer fails entirely, fall through to raw OSC 52. + * + * Outside tmux, write raw OSC 52 to stdout (caller handles the write). + * + * Local (no SSH_CONNECTION): also shell out to a native clipboard utility. + * OSC 52 and tmux -w both depend on terminal settings — iTerm2 disables + * OSC 52 by default, VS Code shows a permission prompt on first use. Native + * utilities (pbcopy/wl-copy/xclip/xsel/clip.exe) always work locally. Over + * SSH these would write to the remote clipboard — OSC 52 is the right path there. + * + * Returns the sequence for the caller to write to stdout (raw OSC 52 + * outside tmux, DCS-wrapped inside). + */ +export async function setClipboard(text: string): Promise { + const b64 = Buffer.from(text, 'utf8').toString('base64') + const raw = osc(OSC.CLIPBOARD, 'c', b64) + + // Native safety net — fire FIRST, before the tmux await, so a quick + // focus-switch after selecting doesn't race pbcopy. Previously this ran + // AFTER awaiting tmux load-buffer, adding ~50-100ms of subprocess latency + // before pbcopy even started — fast cmd+tab → paste would beat it + // (https://anthropic.slack.com/archives/C07VBSHV7EV/p1773943921788829). + // Gated on SSH_CONNECTION (not SSH_TTY) since tmux panes inherit SSH_TTY + // forever but SSH_CONNECTION is in tmux's default update-environment and + // clears on local attach. Fire-and-forget. + if (!process.env['SSH_CONNECTION']) { + copyNative(text) + } + + const tmuxBufferLoaded = await tmuxLoadBuffer(text) + + // Inner OSC uses BEL directly (not osc()) — ST's ESC would need doubling + // too, and BEL works everywhere for OSC 52. + if (tmuxBufferLoaded) { + return tmuxPassthrough(`${ESC}]52;c;${b64}${BEL}`) + } + + return raw +} + +// Linux clipboard tool: undefined = not yet probed, null = none available. +// Probe order: wl-copy (Wayland) → xclip (X11) → xsel (X11 fallback). +// Cached after first attempt so repeated mouse-ups skip the probe chain. +let linuxCopy: 'wl-copy' | 'xclip' | 'xsel' | null | undefined + +/** + * Shell out to a native clipboard utility as a safety net for OSC 52. + * Only called when not in an SSH session (over SSH, these would write to + * the remote machine's clipboard — OSC 52 is the right path there). + * Fire-and-forget: failures are silent since OSC 52 may have succeeded. + */ +function copyNative(text: string): void { + const opts = { input: text, useCwd: false, timeout: 2000 } + + switch (process.platform) { + case 'darwin': + void execFileNoThrow('pbcopy', [], opts) + + return + case 'linux': { + if (linuxCopy === null) { + return + } + + if (linuxCopy === 'wl-copy') { + void execFileNoThrow('wl-copy', [], opts) + + return + } + + if (linuxCopy === 'xclip') { + void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts) + + return + } + + if (linuxCopy === 'xsel') { + void execFileNoThrow('xsel', ['--clipboard', '--input'], opts) + + return + } + + // First call: probe wl-copy (Wayland) then xclip/xsel (X11), cache winner. + void execFileNoThrow('wl-copy', [], opts).then(r => { + if (r.code === 0) { + linuxCopy = 'wl-copy' + + return + } + + void execFileNoThrow('xclip', ['-selection', 'clipboard'], opts).then(r2 => { + if (r2.code === 0) { + linuxCopy = 'xclip' + + return + } + + void execFileNoThrow('xsel', ['--clipboard', '--input'], opts).then(r3 => { + linuxCopy = r3.code === 0 ? 'xsel' : null + }) + }) + }) + + return + } + + case 'win32': + // clip.exe is always available on Windows. Unicode handling is + // imperfect (system locale encoding) but good enough for a fallback. + void execFileNoThrow('clip', [], opts) + + return + } +} + +/** @internal test-only */ +export function _resetLinuxCopyCache(): void { + linuxCopy = undefined +} + +/** + * OSC command numbers + */ +export const OSC = { + SET_TITLE_AND_ICON: 0, + SET_ICON: 1, + SET_TITLE: 2, + SET_COLOR: 4, + SET_CWD: 7, + HYPERLINK: 8, + ITERM2: 9, // iTerm2 proprietary sequences + SET_FG_COLOR: 10, + SET_BG_COLOR: 11, + SET_CURSOR_COLOR: 12, + CLIPBOARD: 52, + KITTY: 99, // Kitty notification protocol + RESET_COLOR: 104, + RESET_FG_COLOR: 110, + RESET_BG_COLOR: 111, + RESET_CURSOR_COLOR: 112, + SEMANTIC_PROMPT: 133, + GHOSTTY: 777, // Ghostty notification protocol + TAB_STATUS: 21337 // Tab status extension +} as const + +/** + * Parse an OSC sequence into an action + * + * @param content - The sequence content (without ESC ] and terminator) + */ +export function parseOSC(content: string): Action | null { + const semicolonIdx = content.indexOf(';') + const command = semicolonIdx >= 0 ? content.slice(0, semicolonIdx) : content + const data = semicolonIdx >= 0 ? content.slice(semicolonIdx + 1) : '' + + const commandNum = parseInt(command, 10) + + // Window/icon title + if (commandNum === OSC.SET_TITLE_AND_ICON) { + return { type: 'title', action: { type: 'both', title: data } } + } + + if (commandNum === OSC.SET_ICON) { + return { type: 'title', action: { type: 'iconName', name: data } } + } + + if (commandNum === OSC.SET_TITLE) { + return { type: 'title', action: { type: 'windowTitle', title: data } } + } + + // Hyperlinks (OSC 8) + if (commandNum === OSC.HYPERLINK) { + const parts = data.split(';') + const paramsStr = parts[0] ?? '' + const url = parts.slice(1).join(';') + + if (url === '') { + return { type: 'link', action: { type: 'end' } } + } + + const params: Record = {} + + if (paramsStr) { + for (const pair of paramsStr.split(':')) { + const eqIdx = pair.indexOf('=') + + if (eqIdx >= 0) { + params[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1) + } + } + } + + return { + type: 'link', + action: { + type: 'start', + url, + params: Object.keys(params).length > 0 ? params : undefined + } + } + } + + // Tab status (OSC 21337) + if (commandNum === OSC.TAB_STATUS) { + return { type: 'tabStatus', action: parseTabStatus(data) } + } + + return { type: 'unknown', sequence: `\x1b]${content}` } +} + +/** + * Parse an XParseColor-style color spec into an RGB Color. + * Accepts `#RRGGBB` and `rgb:R/G/B` (1–4 hex digits per component, scaled + * to 8-bit). Returns null on parse failure. + */ +export function parseOscColor(spec: string): Color | null { + const hex = spec.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i) + + if (hex) { + return { + type: 'rgb', + r: parseInt(hex[1]!, 16), + g: parseInt(hex[2]!, 16), + b: parseInt(hex[3]!, 16) + } + } + + const rgb = spec.match(/^rgb:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})$/i) + + if (rgb) { + // XParseColor: N hex digits → value / (16^N - 1), scale to 0-255 + const scale = (s: string) => Math.round((parseInt(s, 16) / (16 ** s.length - 1)) * 255) + + return { + type: 'rgb', + r: scale(rgb[1]!), + g: scale(rgb[2]!), + b: scale(rgb[3]!) + } + } + + return null +} + +/** + * Parse OSC 21337 payload: `key=value;key=value;...` with `\;` and `\\` + * escapes inside values. Bare key or `key=` clears that field; unknown + * keys are ignored. + */ +function parseTabStatus(data: string): TabStatusAction { + const action: TabStatusAction = {} + + for (const [key, value] of splitTabStatusPairs(data)) { + switch (key) { + case 'indicator': + action.indicator = value === '' ? null : parseOscColor(value) + + break + + case 'status': + action.status = value === '' ? null : value + + break + + case 'status-color': + action.statusColor = value === '' ? null : parseOscColor(value) + + break + } + } + + return action +} + +/** Split `k=v;k=v` honoring `\;` and `\\` escapes. Yields [key, unescapedValue]. */ +function* splitTabStatusPairs(data: string): Generator<[string, string]> { + let key = '' + let val = '' + let inVal = false + let esc = false + + for (const c of data) { + if (esc) { + if (inVal) { + val += c + } else { + key += c + } + + esc = false + } else if (c === '\\') { + esc = true + } else if (c === ';') { + yield [key, val] + key = '' + val = '' + inVal = false + } else if (c === '=' && !inVal) { + inVal = true + } else if (inVal) { + val += c + } else { + key += c + } + } + + if (key || inVal) { + yield [key, val] + } +} + +// Output generators + +/** Start a hyperlink (OSC 8). Auto-assigns an id= param derived from the URL + * so terminals group wrapped lines of the same link together (the spec says + * cells with matching URI *and* nonempty id are joined; without an id each + * wrapped line is a separate link — inconsistent hover, partial tooltips). + * Empty url = close sequence (empty params per spec). */ +export function link(url: string, params?: Record): string { + if (!url) { + return LINK_END + } + + const p = { id: osc8Id(url), ...params } + + const paramStr = Object.entries(p) + .map(([k, v]) => `${k}=${v}`) + .join(':') + + return osc(OSC.HYPERLINK, paramStr, url) +} + +function osc8Id(url: string): string { + let h = 0 + + for (let i = 0; i < url.length; i++) { + h = ((h << 5) - h + url.charCodeAt(i)) | 0 + } + + return (h >>> 0).toString(36) +} + +/** End a hyperlink (OSC 8) */ +export const LINK_END = osc(OSC.HYPERLINK, '', '') + +// iTerm2 OSC 9 subcommands + +/** iTerm2 OSC 9 subcommand numbers */ +export const ITERM2 = { + NOTIFY: 0, + BADGE: 2, + PROGRESS: 4 +} as const + +/** Progress operation codes (for use with ITERM2.PROGRESS) */ +export const PROGRESS = { + CLEAR: 0, + SET: 1, + ERROR: 2, + INDETERMINATE: 3 +} as const + +/** + * Clear iTerm2 progress bar sequence (OSC 9;4;0;BEL) + * Uses BEL terminator since this is for cleanup (not runtime notification) + * and we want to ensure it's always sent regardless of terminal type. + */ +export const CLEAR_ITERM2_PROGRESS = `${OSC_PREFIX}${OSC.ITERM2};${ITERM2.PROGRESS};${PROGRESS.CLEAR};${BEL}` + +/** + * Clear terminal title sequence (OSC 0 with empty string + BEL). + * Uses BEL terminator for cleanup — safe on all terminals. + */ +export const CLEAR_TERMINAL_TITLE = `${OSC_PREFIX}${OSC.SET_TITLE_AND_ICON};${BEL}` + +/** Clear all three OSC 21337 tab-status fields. Used on exit. */ +export const CLEAR_TAB_STATUS = osc(OSC.TAB_STATUS, 'indicator=;status=;status-color=') + +/** + * Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the + * spec is unstable. Terminals that don't recognize it discard silently, so + * emission is safe unconditionally — we don't gate on terminal detection + * since support is expected across several terminals. + * + * Callers must wrap output with wrapForMultiplexer() so tmux/screen + * DCS-passthrough carries the sequence to the outer terminal. + */ +export function supportsTabStatus(): boolean { + return process.env.USER_TYPE === 'ant' +} + +/** + * Emit an OSC 21337 tab-status sequence. Omitted fields are left unchanged + * by the receiving terminal; `null` sends an empty value to clear. + * `;` and `\` in status text are escaped per the spec. + */ +export function tabStatus(fields: TabStatusAction): string { + const parts: string[] = [] + + const rgb = (c: Color) => + c.type === 'rgb' ? `#${[c.r, c.g, c.b].map(n => n.toString(16).padStart(2, '0')).join('')}` : '' + + if ('indicator' in fields) { + parts.push(`indicator=${fields.indicator ? rgb(fields.indicator) : ''}`) + } + + if ('status' in fields) { + parts.push(`status=${fields.status?.replaceAll('\\', '\\\\').replaceAll(';', '\\;') ?? ''}`) + } + + if ('statusColor' in fields) { + parts.push(`status-color=${fields.statusColor ? rgb(fields.statusColor) : ''}`) + } + + return osc(OSC.TAB_STATUS, parts.join(';')) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/parser.ts b/ui-tui/packages/hermes-ink/src/ink/termio/parser.ts new file mode 100644 index 0000000000..0f58d6f203 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/parser.ts @@ -0,0 +1,467 @@ +/** + * ANSI Parser - Semantic Action Generator + * + * A streaming parser for ANSI escape sequences that produces semantic actions. + * Uses the tokenizer for escape sequence boundary detection, then interprets + * each sequence to produce structured actions. + * + * Key design decisions: + * - Streaming: can process input incrementally + * - Semantic output: produces structured actions, not string tokens + * - Style tracking: maintains current text style state + */ + +import { getGraphemeSegmenter } from '../../utils/intl.js' + +import { C0 } from './ansi.js' +import { CSI, CURSOR_STYLES, ERASE_DISPLAY, ERASE_LINE_REGION } from './csi.js' +import { DEC } from './dec.js' +import { parseEsc } from './esc.js' +import { parseOSC } from './osc.js' +import { applySGR } from './sgr.js' +import { createTokenizer, type Token, type Tokenizer } from './tokenize.js' +import type { Action, Grapheme, TextStyle } from './types.js' +import { defaultStyle } from './types.js' + +// ============================================================================= +// Grapheme Utilities +// ============================================================================= + +function isEmoji(codePoint: number): boolean { + return ( + (codePoint >= 0x2600 && codePoint <= 0x26ff) || + (codePoint >= 0x2700 && codePoint <= 0x27bf) || + (codePoint >= 0x1f300 && codePoint <= 0x1f9ff) || + (codePoint >= 0x1fa00 && codePoint <= 0x1faff) || + (codePoint >= 0x1f1e0 && codePoint <= 0x1f1ff) + ) +} + +function isEastAsianWide(codePoint: number): boolean { + return ( + (codePoint >= 0x1100 && codePoint <= 0x115f) || + (codePoint >= 0x2e80 && codePoint <= 0x9fff) || + (codePoint >= 0xac00 && codePoint <= 0xd7a3) || + (codePoint >= 0xf900 && codePoint <= 0xfaff) || + (codePoint >= 0xfe10 && codePoint <= 0xfe1f) || + (codePoint >= 0xfe30 && codePoint <= 0xfe6f) || + (codePoint >= 0xff00 && codePoint <= 0xff60) || + (codePoint >= 0xffe0 && codePoint <= 0xffe6) || + (codePoint >= 0x20000 && codePoint <= 0x2fffd) || + (codePoint >= 0x30000 && codePoint <= 0x3fffd) + ) +} + +function hasMultipleCodepoints(str: string): boolean { + let count = 0 + + for (const _ of str) { + count++ + + if (count > 1) { + return true + } + } + + return false +} + +function graphemeWidth(grapheme: string): 1 | 2 { + if (hasMultipleCodepoints(grapheme)) { + return 2 + } + + const codePoint = grapheme.codePointAt(0) + + if (codePoint === undefined) { + return 1 + } + + if (isEmoji(codePoint) || isEastAsianWide(codePoint)) { + return 2 + } + + return 1 +} + +function* segmentGraphemes(str: string): Generator { + for (const { segment } of getGraphemeSegmenter().segment(str)) { + yield { value: segment, width: graphemeWidth(segment) } + } +} + +// ============================================================================= +// Sequence Parsing +// ============================================================================= + +function parseCSIParams(paramStr: string): number[] { + if (paramStr === '') { + return [] + } + + return paramStr.split(/[;:]/).map(s => (s === '' ? 0 : parseInt(s, 10))) +} + +/** Parse a raw CSI sequence (e.g., "\x1b[31m") into an action */ +function parseCSI(rawSequence: string): Action | null { + const inner = rawSequence.slice(2) + + if (inner.length === 0) { + return null + } + + const finalByte = inner.charCodeAt(inner.length - 1) + const beforeFinal = inner.slice(0, -1) + + let privateMode = '' + let paramStr = beforeFinal + let intermediate = '' + + if (beforeFinal.length > 0 && '?>='.includes(beforeFinal[0]!)) { + privateMode = beforeFinal[0]! + paramStr = beforeFinal.slice(1) + } + + const intermediateMatch = paramStr.match(/([^0-9;:]+)$/) + + if (intermediateMatch) { + intermediate = intermediateMatch[1]! + paramStr = paramStr.slice(0, -intermediate.length) + } + + const params = parseCSIParams(paramStr) + const p0 = params[0] ?? 1 + const p1 = params[1] ?? 1 + + // SGR (Select Graphic Rendition) + if (finalByte === CSI.SGR && privateMode === '') { + return { type: 'sgr', params: paramStr } + } + + // Cursor movement + if (finalByte === CSI.CUU) { + return { + type: 'cursor', + action: { type: 'move', direction: 'up', count: p0 } + } + } + + if (finalByte === CSI.CUD) { + return { + type: 'cursor', + action: { type: 'move', direction: 'down', count: p0 } + } + } + + if (finalByte === CSI.CUF) { + return { + type: 'cursor', + action: { type: 'move', direction: 'forward', count: p0 } + } + } + + if (finalByte === CSI.CUB) { + return { + type: 'cursor', + action: { type: 'move', direction: 'back', count: p0 } + } + } + + if (finalByte === CSI.CNL) { + return { type: 'cursor', action: { type: 'nextLine', count: p0 } } + } + + if (finalByte === CSI.CPL) { + return { type: 'cursor', action: { type: 'prevLine', count: p0 } } + } + + if (finalByte === CSI.CHA) { + return { type: 'cursor', action: { type: 'column', col: p0 } } + } + + if (finalByte === CSI.CUP || finalByte === CSI.HVP) { + return { type: 'cursor', action: { type: 'position', row: p0, col: p1 } } + } + + if (finalByte === CSI.VPA) { + return { type: 'cursor', action: { type: 'row', row: p0 } } + } + + // Erase + if (finalByte === CSI.ED) { + const region = ERASE_DISPLAY[params[0] ?? 0] ?? 'toEnd' + + return { type: 'erase', action: { type: 'display', region } } + } + + if (finalByte === CSI.EL) { + const region = ERASE_LINE_REGION[params[0] ?? 0] ?? 'toEnd' + + return { type: 'erase', action: { type: 'line', region } } + } + + if (finalByte === CSI.ECH) { + return { type: 'erase', action: { type: 'chars', count: p0 } } + } + + // Scroll + if (finalByte === CSI.SU) { + return { type: 'scroll', action: { type: 'up', count: p0 } } + } + + if (finalByte === CSI.SD) { + return { type: 'scroll', action: { type: 'down', count: p0 } } + } + + if (finalByte === CSI.DECSTBM) { + return { + type: 'scroll', + action: { type: 'setRegion', top: p0, bottom: p1 } + } + } + + // Cursor save/restore + if (finalByte === CSI.SCOSC) { + return { type: 'cursor', action: { type: 'save' } } + } + + if (finalByte === CSI.SCORC) { + return { type: 'cursor', action: { type: 'restore' } } + } + + // Cursor style + if (finalByte === CSI.DECSCUSR && intermediate === ' ') { + const styleInfo = CURSOR_STYLES[p0] ?? CURSOR_STYLES[0]! + + return { type: 'cursor', action: { type: 'style', ...styleInfo } } + } + + // Private modes + if (privateMode === '?' && (finalByte === CSI.SM || finalByte === CSI.RM)) { + const enabled = finalByte === CSI.SM + + if (p0 === DEC.CURSOR_VISIBLE) { + return { + type: 'cursor', + action: enabled ? { type: 'show' } : { type: 'hide' } + } + } + + if (p0 === DEC.ALT_SCREEN_CLEAR || p0 === DEC.ALT_SCREEN) { + return { type: 'mode', action: { type: 'alternateScreen', enabled } } + } + + if (p0 === DEC.BRACKETED_PASTE) { + return { type: 'mode', action: { type: 'bracketedPaste', enabled } } + } + + if (p0 === DEC.MOUSE_NORMAL) { + return { + type: 'mode', + action: { type: 'mouseTracking', mode: enabled ? 'normal' : 'off' } + } + } + + if (p0 === DEC.MOUSE_BUTTON) { + return { + type: 'mode', + action: { type: 'mouseTracking', mode: enabled ? 'button' : 'off' } + } + } + + if (p0 === DEC.MOUSE_ANY) { + return { + type: 'mode', + action: { type: 'mouseTracking', mode: enabled ? 'any' : 'off' } + } + } + + if (p0 === DEC.FOCUS_EVENTS) { + return { type: 'mode', action: { type: 'focusEvents', enabled } } + } + } + + return { type: 'unknown', sequence: rawSequence } +} + +/** + * Identify the type of escape sequence from its raw form. + */ +function identifySequence(seq: string): 'csi' | 'osc' | 'esc' | 'ss3' | 'unknown' { + if (seq.length < 2) { + return 'unknown' + } + + if (seq.charCodeAt(0) !== C0.ESC) { + return 'unknown' + } + + const second = seq.charCodeAt(1) + + if (second === 0x5b) { + return 'csi' + } // [ + + if (second === 0x5d) { + return 'osc' + } // ] + + if (second === 0x4f) { + return 'ss3' + } // O + + return 'esc' +} + +// ============================================================================= +// Main Parser +// ============================================================================= + +/** + * Parser class - maintains state for streaming/incremental parsing + * + * Usage: + * ```typescript + * const parser = new Parser() + * const actions1 = parser.feed('partial\x1b[') + * const actions2 = parser.feed('31mred') // state maintained internally + * ``` + */ +export class Parser { + private tokenizer: Tokenizer = createTokenizer() + + style: TextStyle = defaultStyle() + inLink = false + linkUrl: string | undefined + + reset(): void { + this.tokenizer.reset() + this.style = defaultStyle() + this.inLink = false + this.linkUrl = undefined + } + + /** Feed input and get resulting actions */ + feed(input: string): Action[] { + const tokens = this.tokenizer.feed(input) + const actions: Action[] = [] + + for (const token of tokens) { + const tokenActions = this.processToken(token) + actions.push(...tokenActions) + } + + return actions + } + + private processToken(token: Token): Action[] { + switch (token.type) { + case 'text': + return this.processText(token.value) + + case 'sequence': + return this.processSequence(token.value) + } + } + + private processText(text: string): Action[] { + // Handle BEL characters embedded in text + const actions: Action[] = [] + let current = '' + + for (const char of text) { + if (char.charCodeAt(0) === C0.BEL) { + if (current) { + const graphemes = [...segmentGraphemes(current)] + + if (graphemes.length > 0) { + actions.push({ type: 'text', graphemes, style: { ...this.style } }) + } + + current = '' + } + + actions.push({ type: 'bell' }) + } else { + current += char + } + } + + if (current) { + const graphemes = [...segmentGraphemes(current)] + + if (graphemes.length > 0) { + actions.push({ type: 'text', graphemes, style: { ...this.style } }) + } + } + + return actions + } + + private processSequence(seq: string): Action[] { + const seqType = identifySequence(seq) + + switch (seqType) { + case 'csi': { + const action = parseCSI(seq) + + if (!action) { + return [] + } + + if (action.type === 'sgr') { + this.style = applySGR(action.params, this.style) + + return [] + } + + return [action] + } + + case 'osc': { + // Extract OSC content (between ESC ] and terminator) + let content = seq.slice(2) + + // Remove terminator (BEL or ESC \) + if (content.endsWith('\x07')) { + content = content.slice(0, -1) + } else if (content.endsWith('\x1b\\')) { + content = content.slice(0, -2) + } + + const action = parseOSC(content) + + if (action) { + if (action.type === 'link') { + if (action.action.type === 'start') { + this.inLink = true + this.linkUrl = action.action.url + } else { + this.inLink = false + this.linkUrl = undefined + } + } + + return [action] + } + + return [] + } + + case 'esc': { + const escContent = seq.slice(1) + const action = parseEsc(escContent) + + return action ? [action] : [] + } + + case 'ss3': + // SS3 sequences are typically cursor keys in application mode + // For output parsing, treat as unknown + return [{ type: 'unknown', sequence: seq }] + + default: + return [{ type: 'unknown', sequence: seq }] + } + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/sgr.ts b/ui-tui/packages/hermes-ink/src/ink/termio/sgr.ts new file mode 100644 index 0000000000..67a1f6b385 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/sgr.ts @@ -0,0 +1,362 @@ +/** + * SGR (Select Graphic Rendition) Parser + * + * Parses SGR parameters and applies them to a TextStyle. + * Handles both semicolon (;) and colon (:) separated parameters. + */ + +import type { NamedColor, TextStyle, UnderlineStyle } from './types.js' +import { defaultStyle } from './types.js' + +const NAMED_COLORS: NamedColor[] = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] + +const UNDERLINE_STYLES: UnderlineStyle[] = ['none', 'single', 'double', 'curly', 'dotted', 'dashed'] + +type Param = { value: number | null; subparams: number[]; colon: boolean } + +function parseParams(str: string): Param[] { + if (str === '') { + return [{ value: 0, subparams: [], colon: false }] + } + + const result: Param[] = [] + let current: Param = { value: null, subparams: [], colon: false } + let num = '' + let inSub = false + + for (let i = 0; i <= str.length; i++) { + const c = str[i] + + if (c === ';' || c === undefined) { + const n = num === '' ? null : parseInt(num, 10) + + if (inSub) { + if (n !== null) { + current.subparams.push(n) + } + } else { + current.value = n + } + + result.push(current) + current = { value: null, subparams: [], colon: false } + num = '' + inSub = false + } else if (c === ':') { + const n = num === '' ? null : parseInt(num, 10) + + if (!inSub) { + current.value = n + current.colon = true + inSub = true + } else { + if (n !== null) { + current.subparams.push(n) + } + } + + num = '' + } else if (c >= '0' && c <= '9') { + num += c + } + } + + return result +} + +function parseExtendedColor( + params: Param[], + idx: number +): { r: number; g: number; b: number } | { index: number } | null { + const p = params[idx] + + if (!p) { + return null + } + + if (p.colon && p.subparams.length >= 1) { + if (p.subparams[0] === 5 && p.subparams.length >= 2) { + return { index: p.subparams[1]! } + } + + if (p.subparams[0] === 2 && p.subparams.length >= 4) { + const off = p.subparams.length >= 5 ? 1 : 0 + + return { + r: p.subparams[1 + off]!, + g: p.subparams[2 + off]!, + b: p.subparams[3 + off]! + } + } + } + + const next = params[idx + 1] + + if (!next) { + return null + } + + if (next.value === 5 && params[idx + 2]?.value !== null && params[idx + 2]?.value !== undefined) { + return { index: params[idx + 2]!.value! } + } + + if (next.value === 2) { + const r = params[idx + 2]?.value + const g = params[idx + 3]?.value + const b = params[idx + 4]?.value + + if (r !== null && r !== undefined && g !== null && g !== undefined && b !== null && b !== undefined) { + return { r, g, b } + } + } + + return null +} + +export function applySGR(paramStr: string, style: TextStyle): TextStyle { + const params = parseParams(paramStr) + let s = { ...style } + let i = 0 + + while (i < params.length) { + const p = params[i]! + const code = p.value ?? 0 + + if (code === 0) { + s = defaultStyle() + i++ + + continue + } + + if (code === 1) { + s.bold = true + i++ + + continue + } + + if (code === 2) { + s.dim = true + i++ + + continue + } + + if (code === 3) { + s.italic = true + i++ + + continue + } + + if (code === 4) { + s.underline = p.colon ? (UNDERLINE_STYLES[p.subparams[0]!] ?? 'single') : 'single' + i++ + + continue + } + + if (code === 5 || code === 6) { + s.blink = true + i++ + + continue + } + + if (code === 7) { + s.inverse = true + i++ + + continue + } + + if (code === 8) { + s.hidden = true + i++ + + continue + } + + if (code === 9) { + s.strikethrough = true + i++ + + continue + } + + if (code === 21) { + s.underline = 'double' + i++ + + continue + } + + if (code === 22) { + s.bold = false + s.dim = false + i++ + + continue + } + + if (code === 23) { + s.italic = false + i++ + + continue + } + + if (code === 24) { + s.underline = 'none' + i++ + + continue + } + + if (code === 25) { + s.blink = false + i++ + + continue + } + + if (code === 27) { + s.inverse = false + i++ + + continue + } + + if (code === 28) { + s.hidden = false + i++ + + continue + } + + if (code === 29) { + s.strikethrough = false + i++ + + continue + } + + if (code === 53) { + s.overline = true + i++ + + continue + } + + if (code === 55) { + s.overline = false + i++ + + continue + } + + if (code >= 30 && code <= 37) { + s.fg = { type: 'named', name: NAMED_COLORS[code - 30]! } + i++ + + continue + } + + if (code === 39) { + s.fg = { type: 'default' } + i++ + + continue + } + + if (code >= 40 && code <= 47) { + s.bg = { type: 'named', name: NAMED_COLORS[code - 40]! } + i++ + + continue + } + + if (code === 49) { + s.bg = { type: 'default' } + i++ + + continue + } + + if (code >= 90 && code <= 97) { + s.fg = { type: 'named', name: NAMED_COLORS[code - 90 + 8]! } + i++ + + continue + } + + if (code >= 100 && code <= 107) { + s.bg = { type: 'named', name: NAMED_COLORS[code - 100 + 8]! } + i++ + + continue + } + + if (code === 38) { + const c = parseExtendedColor(params, i) + + if (c) { + s.fg = 'index' in c ? { type: 'indexed', index: c.index } : { type: 'rgb', ...c } + i += p.colon ? 1 : 'index' in c ? 3 : 5 + + continue + } + } + + if (code === 48) { + const c = parseExtendedColor(params, i) + + if (c) { + s.bg = 'index' in c ? { type: 'indexed', index: c.index } : { type: 'rgb', ...c } + i += p.colon ? 1 : 'index' in c ? 3 : 5 + + continue + } + } + + if (code === 58) { + const c = parseExtendedColor(params, i) + + if (c) { + s.underlineColor = 'index' in c ? { type: 'indexed', index: c.index } : { type: 'rgb', ...c } + i += p.colon ? 1 : 'index' in c ? 3 : 5 + + continue + } + } + + if (code === 59) { + s.underlineColor = { type: 'default' } + i++ + + continue + } + + i++ + } + + return s +} diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts b/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts new file mode 100644 index 0000000000..40ba7e2143 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/tokenize.ts @@ -0,0 +1,316 @@ +/** + * Input Tokenizer - Escape sequence boundary detection + * + * Splits terminal input into tokens: text chunks and raw escape sequences. + * Unlike the Parser which interprets sequences semantically, this just + * identifies boundaries for use by keyboard input parsing. + */ + +import { C0, ESC_TYPE, isEscFinal } from './ansi.js' +import { isCSIFinal, isCSIIntermediate, isCSIParam } from './csi.js' + +export type Token = { type: 'text'; value: string } | { type: 'sequence'; value: string } + +type State = 'ground' | 'escape' | 'escapeIntermediate' | 'csi' | 'ss3' | 'osc' | 'dcs' | 'apc' + +export type Tokenizer = { + /** Feed input and get resulting tokens */ + feed(input: string): Token[] + /** Flush any buffered incomplete sequences */ + flush(): Token[] + /** Reset tokenizer state */ + reset(): void + /** Get any buffered incomplete sequence */ + buffer(): string +} + +type TokenizerOptions = { + /** + * Treat `CSI M` as an X10 mouse event prefix and consume 3 payload bytes. + * Only enable for stdin input — `\x1b[M` is also CSI DL (Delete Lines) in + * output streams, and enabling this there swallows display text. Default false. + */ + x10Mouse?: boolean +} + +/** + * Create a streaming tokenizer for terminal input. + * + * Usage: + * ```typescript + * const tokenizer = createTokenizer() + * const tokens1 = tokenizer.feed('hello\x1b[') + * const tokens2 = tokenizer.feed('A') // completes the escape sequence + * const remaining = tokenizer.flush() // force output incomplete sequences + * ``` + */ +export function createTokenizer(options?: TokenizerOptions): Tokenizer { + let currentState: State = 'ground' + let currentBuffer = '' + const x10Mouse = options?.x10Mouse ?? false + + return { + feed(input: string): Token[] { + const result = tokenize(input, currentState, currentBuffer, false, x10Mouse) + + currentState = result.state.state + currentBuffer = result.state.buffer + + return result.tokens + }, + + flush(): Token[] { + const result = tokenize('', currentState, currentBuffer, true, x10Mouse) + currentState = result.state.state + currentBuffer = result.state.buffer + + return result.tokens + }, + + reset(): void { + currentState = 'ground' + currentBuffer = '' + }, + + buffer(): string { + return currentBuffer + } + } +} + +type InternalState = { + state: State + buffer: string +} + +function tokenize( + input: string, + initialState: State, + initialBuffer: string, + flush: boolean, + x10Mouse: boolean +): { tokens: Token[]; state: InternalState } { + const tokens: Token[] = [] + + const result: InternalState = { + state: initialState, + buffer: '' + } + + const data = initialBuffer + input + let i = 0 + let textStart = 0 + let seqStart = 0 + + const flushText = (): void => { + if (i > textStart) { + const text = data.slice(textStart, i) + + if (text) { + tokens.push({ type: 'text', value: text }) + } + } + + textStart = i + } + + const emitSequence = (seq: string): void => { + if (seq) { + tokens.push({ type: 'sequence', value: seq }) + } + + result.state = 'ground' + textStart = i + } + + while (i < data.length) { + const code = data.charCodeAt(i) + + switch (result.state) { + case 'ground': + if (code === C0.ESC) { + flushText() + seqStart = i + result.state = 'escape' + i++ + } else { + i++ + } + + break + + case 'escape': + if (code === ESC_TYPE.CSI) { + result.state = 'csi' + i++ + } else if (code === ESC_TYPE.OSC) { + result.state = 'osc' + i++ + } else if (code === ESC_TYPE.DCS) { + result.state = 'dcs' + i++ + } else if (code === ESC_TYPE.APC) { + result.state = 'apc' + i++ + } else if (code === 0x4f) { + // 'O' - SS3 + result.state = 'ss3' + i++ + } else if (isCSIIntermediate(code)) { + // Intermediate byte (e.g., ESC ( for charset) - continue buffering + result.state = 'escapeIntermediate' + i++ + } else if (isEscFinal(code)) { + // Two-character escape sequence + i++ + emitSequence(data.slice(seqStart, i)) + } else if (code === C0.ESC) { + // Double escape - emit first, start new + emitSequence(data.slice(seqStart, i)) + seqStart = i + result.state = 'escape' + i++ + } else { + // Invalid - treat ESC as text + result.state = 'ground' + textStart = seqStart + } + + break + + case 'escapeIntermediate': + // After intermediate byte(s), wait for final byte + if (isCSIIntermediate(code)) { + // More intermediate bytes + i++ + } else if (isEscFinal(code)) { + // Final byte - complete the sequence + i++ + emitSequence(data.slice(seqStart, i)) + } else { + // Invalid - treat as text + result.state = 'ground' + textStart = seqStart + } + + break + + case 'csi': + // X10 mouse: CSI M + 3 raw payload bytes (Cb+32, Cx+32, Cy+32). + // M immediately after [ (offset 2) means no params — SGR mouse + // (CSI < … M) has a `<` param byte first and reaches M at offset > 2. + // Terminals that ignore DECSET 1006 but honor 1000/1002 emit this + // legacy encoding; without this branch the 3 payload bytes leak + // through as text (`` `rK `` / `arK` garbage in the prompt). + // + // Gated on x10Mouse — `\x1b[M` is also CSI DL (Delete Lines) and + // blindly consuming 3 chars corrupts output rendering (Parser/Ansi) + // and fragments bracketed-paste PASTE_END. Only stdin enables this. + // The ≥0x20 check on each payload slot is belt-and-suspenders: X10 + // guarantees Cb≥32, Cx≥33, Cy≥33, so a control byte (ESC=0x1B) in + // any slot means this is CSI DL adjacent to another sequence, not a + // mouse event. Checking all three slots prevents PASTE_END's ESC + // from being consumed when paste content ends in `\x1b[M`+0-2 chars. + // + // Known limitation: this counts JS string chars, but X10 is byte- + // oriented and stdin uses utf8 encoding (App.tsx). At col 162-191 × + // row 96-159 the two coord bytes (0xC2-0xDF, 0x80-0xBF) form a valid + // UTF-8 2-byte sequence and collapse to one char — the length check + // fails and the event buffers until the next keypress absorbs it. + // Fixing this requires latin1 stdin; X10's 223-coord cap is exactly + // why SGR was invented, and no-SGR terminals at 162+ cols are rare. + if ( + x10Mouse && + code === 0x4d /* M */ && + i - seqStart === 2 && + (i + 1 >= data.length || data.charCodeAt(i + 1) >= 0x20) && + (i + 2 >= data.length || data.charCodeAt(i + 2) >= 0x20) && + (i + 3 >= data.length || data.charCodeAt(i + 3) >= 0x20) + ) { + if (i + 4 <= data.length) { + i += 4 + emitSequence(data.slice(seqStart, i)) + } else { + // Incomplete — exit loop; end-of-input buffers from seqStart. + // Re-entry re-tokenizes from ground via the invalid-CSI fallthrough. + i = data.length + } + + break + } + + if (isCSIFinal(code)) { + i++ + emitSequence(data.slice(seqStart, i)) + } else if (isCSIParam(code) || isCSIIntermediate(code)) { + i++ + } else { + // Invalid CSI - abort, treat as text + result.state = 'ground' + textStart = seqStart + } + + break + + case 'ss3': + // SS3 sequences: ESC O followed by a single final byte + if (code >= 0x40 && code <= 0x7e) { + i++ + emitSequence(data.slice(seqStart, i)) + } else { + // Invalid - treat as text + result.state = 'ground' + textStart = seqStart + } + + break + + case 'osc': + if (code === C0.BEL) { + i++ + emitSequence(data.slice(seqStart, i)) + } else if (code === C0.ESC && i + 1 < data.length && data.charCodeAt(i + 1) === ESC_TYPE.ST) { + i += 2 + emitSequence(data.slice(seqStart, i)) + } else { + i++ + } + + break + + case 'dcs': + + case 'apc': + if (code === C0.BEL) { + i++ + emitSequence(data.slice(seqStart, i)) + } else if (code === C0.ESC && i + 1 < data.length && data.charCodeAt(i + 1) === ESC_TYPE.ST) { + i += 2 + emitSequence(data.slice(seqStart, i)) + } else { + i++ + } + + break + } + } + + // Handle end of input + if (result.state === 'ground') { + flushText() + } else if (flush) { + // Force output incomplete sequence + const remaining = data.slice(seqStart) + + if (remaining) { + tokens.push({ type: 'sequence', value: remaining }) + } + + result.state = 'ground' + } else { + // Buffer incomplete sequence for next call + result.buffer = data.slice(seqStart) + } + + return { tokens, state: result } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/termio/types.ts b/ui-tui/packages/hermes-ink/src/ink/termio/types.ts new file mode 100644 index 0000000000..4af1dc4cec --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/termio/types.ts @@ -0,0 +1,230 @@ +/** + * ANSI Parser - Semantic Types + * + * These types represent the semantic meaning of ANSI escape sequences, + * not their string representation. Inspired by ghostty's action-based design. + */ + +// ============================================================================= +// Colors +// ============================================================================= + +/** Named colors from the 16-color palette */ +export type NamedColor = + | 'black' + | 'red' + | 'green' + | 'yellow' + | 'blue' + | 'magenta' + | 'cyan' + | 'white' + | 'brightBlack' + | 'brightRed' + | 'brightGreen' + | 'brightYellow' + | 'brightBlue' + | 'brightMagenta' + | 'brightCyan' + | 'brightWhite' + +/** Color specification - can be named, indexed (256), or RGB */ +export type Color = + | { type: 'named'; name: NamedColor } + | { type: 'indexed'; index: number } // 0-255 + | { type: 'rgb'; r: number; g: number; b: number } + | { type: 'default' } + +// ============================================================================= +// Text Styles +// ============================================================================= + +/** Underline style variants */ +export type UnderlineStyle = 'none' | 'single' | 'double' | 'curly' | 'dotted' | 'dashed' + +/** Text style attributes - represents current styling state */ +export type TextStyle = { + bold: boolean + dim: boolean + italic: boolean + underline: UnderlineStyle + blink: boolean + inverse: boolean + hidden: boolean + strikethrough: boolean + overline: boolean + fg: Color + bg: Color + underlineColor: Color +} + +/** Create a default (reset) text style */ +export function defaultStyle(): TextStyle { + return { + bold: false, + dim: false, + italic: false, + underline: 'none', + blink: false, + inverse: false, + hidden: false, + strikethrough: false, + overline: false, + fg: { type: 'default' }, + bg: { type: 'default' }, + underlineColor: { type: 'default' } + } +} + +/** Check if two styles are equal */ +export function stylesEqual(a: TextStyle, b: TextStyle): boolean { + return ( + a.bold === b.bold && + a.dim === b.dim && + a.italic === b.italic && + a.underline === b.underline && + a.blink === b.blink && + a.inverse === b.inverse && + a.hidden === b.hidden && + a.strikethrough === b.strikethrough && + a.overline === b.overline && + colorsEqual(a.fg, b.fg) && + colorsEqual(a.bg, b.bg) && + colorsEqual(a.underlineColor, b.underlineColor) + ) +} + +/** Check if two colors are equal */ +export function colorsEqual(a: Color, b: Color): boolean { + if (a.type !== b.type) { + return false + } + + switch (a.type) { + case 'named': + return a.name === (b as typeof a).name + + case 'indexed': + return a.index === (b as typeof a).index + + case 'rgb': + return a.r === (b as typeof a).r && a.g === (b as typeof a).g && a.b === (b as typeof a).b + + case 'default': + return true + } +} + +// ============================================================================= +// Cursor Actions +// ============================================================================= + +export type CursorDirection = 'up' | 'down' | 'forward' | 'back' + +export type CursorAction = + | { type: 'move'; direction: CursorDirection; count: number } + | { type: 'position'; row: number; col: number } + | { type: 'column'; col: number } + | { type: 'row'; row: number } + | { type: 'save' } + | { type: 'restore' } + | { type: 'show' } + | { type: 'hide' } + | { + type: 'style' + style: 'block' | 'underline' | 'bar' + blinking: boolean + } + | { type: 'nextLine'; count: number } + | { type: 'prevLine'; count: number } + +// ============================================================================= +// Erase Actions +// ============================================================================= + +export type EraseAction = + | { type: 'display'; region: 'toEnd' | 'toStart' | 'all' | 'scrollback' } + | { type: 'line'; region: 'toEnd' | 'toStart' | 'all' } + | { type: 'chars'; count: number } + +// ============================================================================= +// Scroll Actions +// ============================================================================= + +export type ScrollAction = + | { type: 'up'; count: number } + | { type: 'down'; count: number } + | { type: 'setRegion'; top: number; bottom: number } + +// ============================================================================= +// Mode Actions +// ============================================================================= + +export type ModeAction = + | { type: 'alternateScreen'; enabled: boolean } + | { type: 'bracketedPaste'; enabled: boolean } + | { type: 'mouseTracking'; mode: 'off' | 'normal' | 'button' | 'any' } + | { type: 'focusEvents'; enabled: boolean } + +// ============================================================================= +// Link Actions (OSC 8) +// ============================================================================= + +export type LinkAction = { type: 'start'; url: string; params?: Record } | { type: 'end' } + +// ============================================================================= +// Title Actions (OSC 0/1/2) +// ============================================================================= + +export type TitleAction = + | { type: 'windowTitle'; title: string } + | { type: 'iconName'; name: string } + | { type: 'both'; title: string } + +// ============================================================================= +// Tab Status Action (OSC 21337) +// ============================================================================= + +/** + * Per-tab chrome metadata. Tristate for each field: + * - property absent → not mentioned in sequence, no change + * - null → explicitly cleared (bare key or key= with empty value) + * - value → set to this + */ +export type TabStatusAction = { + indicator?: Color | null + status?: string | null + statusColor?: Color | null +} + +// ============================================================================= +// Parsed Segments - The output of the parser +// ============================================================================= + +/** A segment of styled text */ +export type TextSegment = { + type: 'text' + text: string + style: TextStyle +} + +/** A grapheme (visual character unit) with width info */ +export type Grapheme = { + value: string + width: 1 | 2 // Display width in columns +} + +/** All possible parsed actions */ +export type Action = + | { type: 'text'; graphemes: Grapheme[]; style: TextStyle } + | { type: 'cursor'; action: CursorAction } + | { type: 'erase'; action: EraseAction } + | { type: 'scroll'; action: ScrollAction } + | { type: 'mode'; action: ModeAction } + | { type: 'link'; action: LinkAction } + | { type: 'title'; action: TitleAction } + | { type: 'tabStatus'; action: TabStatusAction } + | { type: 'sgr'; params: string } // Select Graphic Rendition (style change) + | { type: 'bell' } + | { type: 'reset' } // Full terminal reset (ESC c) + | { type: 'unknown'; sequence: string } // Unrecognized sequence diff --git a/ui-tui/packages/hermes-ink/src/ink/useTerminalNotification.ts b/ui-tui/packages/hermes-ink/src/ink/useTerminalNotification.ts new file mode 100644 index 0000000000..1fcde2bdb0 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/useTerminalNotification.ts @@ -0,0 +1,110 @@ +import { createContext, useCallback, useContext, useMemo } from 'react' + +import { isProgressReportingAvailable, type Progress } from './terminal.js' +import { BEL } from './termio/ansi.js' +import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from './termio/osc.js' + +type WriteRaw = (data: string) => void + +export const TerminalWriteContext = createContext(null) + +export const TerminalWriteProvider = TerminalWriteContext.Provider + +export type TerminalNotification = { + notifyITerm2: (opts: { message: string; title?: string }) => void + notifyKitty: (opts: { message: string; title: string; id: number }) => void + notifyGhostty: (opts: { message: string; title: string }) => void + notifyBell: () => void + /** + * Report progress to the terminal via OSC 9;4 sequences. + * Supported terminals: ConEmu, Ghostty 1.2.0+, iTerm2 3.6.6+ + * Pass state=null to clear progress. + */ + progress: (state: Progress['state'] | null, percentage?: number) => void +} + +export function useTerminalNotification(): TerminalNotification { + const writeRaw = useContext(TerminalWriteContext) + + if (!writeRaw) { + throw new Error('useTerminalNotification must be used within TerminalWriteProvider') + } + + const notifyITerm2 = useCallback( + ({ message, title }: { message: string; title?: string }) => { + const displayString = title ? `${title}:\n${message}` : message + writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${displayString}`))) + }, + [writeRaw] + ) + + const notifyKitty = useCallback( + ({ message, title, id }: { message: string; title: string; id: number }) => { + writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title))) + writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message))) + writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, ''))) + }, + [writeRaw] + ) + + const notifyGhostty = useCallback( + ({ message, title }: { message: string; title: string }) => { + writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message))) + }, + [writeRaw] + ) + + const notifyBell = useCallback(() => { + // Raw BEL — inside tmux this triggers tmux's bell-action (window flag). + // Wrapping would make it opaque DCS payload and lose that fallback. + writeRaw(BEL) + }, [writeRaw]) + + const progress = useCallback( + (state: Progress['state'] | null, percentage?: number) => { + if (!isProgressReportingAvailable()) { + return + } + + if (!state) { + writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''))) + + return + } + + const pct = Math.max(0, Math.min(100, Math.round(percentage ?? 0))) + + switch (state) { + case 'completed': + writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''))) + + break + + case 'error': + writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.ERROR, pct))) + + break + + case 'indeterminate': + writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.INDETERMINATE, ''))) + + break + + case 'running': + writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.SET, pct))) + + break + + case null: + // Handled by the if guard above + break + } + }, + [writeRaw] + ) + + return useMemo( + () => ({ notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress }), + [notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress] + ) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/warn.ts b/ui-tui/packages/hermes-ink/src/ink/warn.ts new file mode 100644 index 0000000000..016b4ecd2b --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/warn.ts @@ -0,0 +1,15 @@ +import { logForDebugging } from '../utils/debug.js' + +export function ifNotInteger(value: number | undefined, name: string): void { + if (value === undefined) { + return + } + + if (Number.isInteger(value)) { + return + } + + logForDebugging(`${name} should be an integer, got ${value}`, { + level: 'warn' + }) +} diff --git a/ui-tui/packages/hermes-ink/src/ink/widest-line.ts b/ui-tui/packages/hermes-ink/src/ink/widest-line.ts new file mode 100644 index 0000000000..ac78cb6d5a --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/widest-line.ts @@ -0,0 +1,22 @@ +import { lineWidth } from './line-width-cache.js' + +export function widestLine(string: string): number { + let maxWidth = 0 + let start = 0 + + while (start <= string.length) { + const end = string.indexOf('\n', start) + + const line = end === -1 ? string.substring(start) : string.substring(start, end) + + maxWidth = Math.max(maxWidth, lineWidth(line)) + + if (end === -1) { + break + } + + start = end + 1 + } + + return maxWidth +} diff --git a/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts new file mode 100644 index 0000000000..4d157bc2af --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/wrap-text.ts @@ -0,0 +1,75 @@ +import sliceAnsi from '../utils/sliceAnsi.js' + +import { stringWidth } from './stringWidth.js' +import type { Styles } from './styles.js' +import { wrapAnsi } from './wrapAnsi.js' + +const ELLIPSIS = '…' + +// sliceAnsi may include a boundary-spanning wide char (e.g. CJK at position +// end-1 with width 2 overshoots by 1). Retry with a tighter bound once. +function sliceFit(text: string, start: number, end: number): string { + const s = sliceAnsi(text, start, end) + + return stringWidth(s) > end - start ? sliceAnsi(text, start, end - 1) : s +} + +function truncate(text: string, columns: number, position: 'start' | 'middle' | 'end'): string { + if (columns < 1) { + return '' + } + + if (columns === 1) { + return ELLIPSIS + } + + const length = stringWidth(text) + + if (length <= columns) { + return text + } + + if (position === 'start') { + return ELLIPSIS + sliceFit(text, length - columns + 1, length) + } + + if (position === 'middle') { + const half = Math.floor(columns / 2) + + return sliceFit(text, 0, half) + ELLIPSIS + sliceFit(text, length - (columns - half) + 1, length) + } + + return sliceFit(text, 0, columns - 1) + ELLIPSIS +} + +export default function wrapText(text: string, maxWidth: number, wrapType: Styles['textWrap']): string { + if (wrapType === 'wrap') { + return wrapAnsi(text, maxWidth, { + trim: false, + hard: true + }) + } + + if (wrapType === 'wrap-trim') { + return wrapAnsi(text, maxWidth, { + trim: true, + hard: true + }) + } + + if (wrapType!.startsWith('truncate')) { + let position: 'end' | 'middle' | 'start' = 'end' + + if (wrapType === 'truncate-middle') { + position = 'middle' + } + + if (wrapType === 'truncate-start') { + position = 'start' + } + + return truncate(text, maxWidth, position) + } + + return text +} diff --git a/ui-tui/packages/hermes-ink/src/ink/wrapAnsi.ts b/ui-tui/packages/hermes-ink/src/ink/wrapAnsi.ts new file mode 100644 index 0000000000..61b56dbf3f --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/wrapAnsi.ts @@ -0,0 +1,13 @@ +import wrapAnsiNpm from 'wrap-ansi' + +type WrapAnsiOptions = { + hard?: boolean + wordWrap?: boolean + trim?: boolean +} + +const wrapAnsiBun = typeof Bun !== 'undefined' && typeof Bun.wrapAnsi === 'function' ? Bun.wrapAnsi : null + +const wrapAnsi: (input: string, columns: number, options?: WrapAnsiOptions) => string = wrapAnsiBun ?? wrapAnsiNpm + +export { wrapAnsi } diff --git a/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/enums.ts b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/enums.ts new file mode 100644 index 0000000000..95d66bf348 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/enums.ts @@ -0,0 +1,112 @@ +export const Align = { + Auto: 0, + FlexStart: 1, + Center: 2, + FlexEnd: 3, + Stretch: 4, + Baseline: 5, + SpaceBetween: 6, + SpaceAround: 7, + SpaceEvenly: 8 +} as const +export type Align = (typeof Align)[keyof typeof Align] +export const BoxSizing = { + BorderBox: 0, + ContentBox: 1 +} as const +export type BoxSizing = (typeof BoxSizing)[keyof typeof BoxSizing] +export const Dimension = { + Width: 0, + Height: 1 +} as const +export type Dimension = (typeof Dimension)[keyof typeof Dimension] +export const Direction = { + Inherit: 0, + LTR: 1, + RTL: 2 +} as const +export type Direction = (typeof Direction)[keyof typeof Direction] +export const Display = { + Flex: 0, + None: 1, + Contents: 2 +} as const +export type Display = (typeof Display)[keyof typeof Display] +export const Edge = { + Left: 0, + Top: 1, + Right: 2, + Bottom: 3, + Start: 4, + End: 5, + Horizontal: 6, + Vertical: 7, + All: 8 +} as const +export type Edge = (typeof Edge)[keyof typeof Edge] +export const Errata = { + None: 0, + StretchFlexBasis: 1, + AbsolutePositionWithoutInsetsExcludesPadding: 2, + AbsolutePercentAgainstInnerSize: 4, + All: 2147483647, + Classic: 2147483646 +} as const +export type Errata = (typeof Errata)[keyof typeof Errata] +export const ExperimentalFeature = { + WebFlexBasis: 0 +} as const +export type ExperimentalFeature = (typeof ExperimentalFeature)[keyof typeof ExperimentalFeature] +export const FlexDirection = { + Column: 0, + ColumnReverse: 1, + Row: 2, + RowReverse: 3 +} as const +export type FlexDirection = (typeof FlexDirection)[keyof typeof FlexDirection] +export const Gutter = { + Column: 0, + Row: 1, + All: 2 +} as const +export type Gutter = (typeof Gutter)[keyof typeof Gutter] +export const Justify = { + FlexStart: 0, + Center: 1, + FlexEnd: 2, + SpaceBetween: 3, + SpaceAround: 4, + SpaceEvenly: 5 +} as const +export type Justify = (typeof Justify)[keyof typeof Justify] +export const MeasureMode = { + Undefined: 0, + Exactly: 1, + AtMost: 2 +} as const +export type MeasureMode = (typeof MeasureMode)[keyof typeof MeasureMode] +export const Overflow = { + Visible: 0, + Hidden: 1, + Scroll: 2 +} as const +export type Overflow = (typeof Overflow)[keyof typeof Overflow] +export const PositionType = { + Static: 0, + Relative: 1, + Absolute: 2 +} as const +export type PositionType = (typeof PositionType)[keyof typeof PositionType] +export const Unit = { + Undefined: 0, + Point: 1, + Percent: 2, + Auto: 3 +} as const +export type Unit = (typeof Unit)[keyof typeof Unit] +export const Wrap = { + NoWrap: 0, + Wrap: 1, + WrapReverse: 2 +} as const +export type Wrap = (typeof Wrap)[keyof typeof Wrap] diff --git a/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts new file mode 100644 index 0000000000..a62a4bae16 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/native-ts/yoga-layout/index.ts @@ -0,0 +1,2326 @@ +import { + Align, + BoxSizing, + Dimension, + Direction, + Display, + Edge, + Errata, + ExperimentalFeature, + FlexDirection, + Gutter, + Justify, + MeasureMode, + Overflow, + PositionType, + Unit, + Wrap +} from './enums.js' +export { + Align, + BoxSizing, + Dimension, + Direction, + Display, + Edge, + Errata, + ExperimentalFeature, + FlexDirection, + Gutter, + Justify, + MeasureMode, + Overflow, + PositionType, + Unit, + Wrap +} +export type Value = { + unit: Unit + value: number +} +const UNDEFINED_VALUE: Value = { unit: Unit.Undefined, value: NaN } +const AUTO_VALUE: Value = { unit: Unit.Auto, value: NaN } + +function pointValue(v: number): Value { + return { unit: Unit.Point, value: v } +} + +function percentValue(v: number): Value { + return { unit: Unit.Percent, value: v } +} + +function resolveValue(v: Value, ownerSize: number): number { + switch (v.unit) { + case Unit.Point: + return v.value + + case Unit.Percent: + return isNaN(ownerSize) ? NaN : (v.value * ownerSize) / 100 + + default: + return NaN + } +} + +function isDefined(n: number): boolean { + return !isNaN(n) +} + +function sameFloat(a: number, b: number): boolean { + return a === b || (a !== a && b !== b) +} + +type Layout = { + left: number + top: number + width: number + height: number + border: [number, number, number, number] + padding: [number, number, number, number] + margin: [number, number, number, number] +} +type Style = { + direction: Direction + flexDirection: FlexDirection + justifyContent: Justify + alignItems: Align + alignSelf: Align + alignContent: Align + flexWrap: Wrap + overflow: Overflow + display: Display + positionType: PositionType + flexGrow: number + flexShrink: number + flexBasis: Value + margin: Value[] + padding: Value[] + border: Value[] + position: Value[] + gap: Value[] + width: Value + height: Value + minWidth: Value + minHeight: Value + maxWidth: Value + maxHeight: Value +} + +function defaultStyle(): Style { + return { + direction: Direction.Inherit, + flexDirection: FlexDirection.Column, + justifyContent: Justify.FlexStart, + alignItems: Align.Stretch, + alignSelf: Align.Auto, + alignContent: Align.FlexStart, + flexWrap: Wrap.NoWrap, + overflow: Overflow.Visible, + display: Display.Flex, + positionType: PositionType.Relative, + flexGrow: 0, + flexShrink: 0, + flexBasis: AUTO_VALUE, + margin: new Array(9).fill(UNDEFINED_VALUE), + padding: new Array(9).fill(UNDEFINED_VALUE), + border: new Array(9).fill(UNDEFINED_VALUE), + position: new Array(9).fill(UNDEFINED_VALUE), + gap: new Array(3).fill(UNDEFINED_VALUE), + width: AUTO_VALUE, + height: AUTO_VALUE, + minWidth: UNDEFINED_VALUE, + minHeight: UNDEFINED_VALUE, + maxWidth: UNDEFINED_VALUE, + maxHeight: UNDEFINED_VALUE + } +} + +const EDGE_LEFT = 0 +const EDGE_TOP = 1 +const EDGE_RIGHT = 2 +const EDGE_BOTTOM = 3 + +function resolveEdge(edges: Value[], physicalEdge: number, ownerSize: number, allowAuto = false): number { + let v = edges[physicalEdge]! + + if (v.unit === Unit.Undefined) { + if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) { + v = edges[Edge.Horizontal]! + } else { + v = edges[Edge.Vertical]! + } + } + + if (v.unit === Unit.Undefined) { + v = edges[Edge.All]! + } + + if (v.unit === Unit.Undefined) { + if (physicalEdge === EDGE_LEFT) { + v = edges[Edge.Start]! + } + + if (physicalEdge === EDGE_RIGHT) { + v = edges[Edge.End]! + } + } + + if (v.unit === Unit.Undefined) { + return 0 + } + + if (v.unit === Unit.Auto) { + return allowAuto ? NaN : 0 + } + + return resolveValue(v, ownerSize) +} + +function resolveEdgeRaw(edges: Value[], physicalEdge: number): Value { + let v = edges[physicalEdge]! + + if (v.unit === Unit.Undefined) { + if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) { + v = edges[Edge.Horizontal]! + } else { + v = edges[Edge.Vertical]! + } + } + + if (v.unit === Unit.Undefined) { + v = edges[Edge.All]! + } + + if (v.unit === Unit.Undefined) { + if (physicalEdge === EDGE_LEFT) { + v = edges[Edge.Start]! + } + + if (physicalEdge === EDGE_RIGHT) { + v = edges[Edge.End]! + } + } + + return v +} + +function isMarginAuto(edges: Value[], physicalEdge: number): boolean { + return resolveEdgeRaw(edges, physicalEdge).unit === Unit.Auto +} + +function hasAnyAutoEdge(edges: Value[]): boolean { + for (let i = 0; i < 9; i++) { + if (edges[i]!.unit === 3) { + return true + } + } + + return false +} + +function hasAnyDefinedEdge(edges: Value[]): boolean { + for (let i = 0; i < 9; i++) { + if (edges[i]!.unit !== 0) { + return true + } + } + + return false +} + +function resolveEdges4Into(edges: Value[], ownerSize: number, out: [number, number, number, number]): void { + const eH = edges[6]! + const eV = edges[7]! + const eA = edges[8]! + const eS = edges[4]! + const eE = edges[5]! + const pctDenom = isNaN(ownerSize) ? NaN : ownerSize / 100 + let v = edges[0]! + + if (v.unit === 0) { + v = eH + } + + if (v.unit === 0) { + v = eA + } + + if (v.unit === 0) { + v = eS + } + + out[0] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 + v = edges[1]! + + if (v.unit === 0) { + v = eV + } + + if (v.unit === 0) { + v = eA + } + + out[1] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 + v = edges[2]! + + if (v.unit === 0) { + v = eH + } + + if (v.unit === 0) { + v = eA + } + + if (v.unit === 0) { + v = eE + } + + out[2] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 + v = edges[3]! + + if (v.unit === 0) { + v = eV + } + + if (v.unit === 0) { + v = eA + } + + out[3] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 +} + +function isRow(dir: FlexDirection): boolean { + return dir === FlexDirection.Row || dir === FlexDirection.RowReverse +} + +function isReverse(dir: FlexDirection): boolean { + return dir === FlexDirection.RowReverse || dir === FlexDirection.ColumnReverse +} + +function crossAxis(dir: FlexDirection): FlexDirection { + return isRow(dir) ? FlexDirection.Column : FlexDirection.Row +} + +function leadingEdge(dir: FlexDirection): number { + switch (dir) { + case FlexDirection.Row: + return EDGE_LEFT + + case FlexDirection.RowReverse: + return EDGE_RIGHT + + case FlexDirection.Column: + return EDGE_TOP + + case FlexDirection.ColumnReverse: + return EDGE_BOTTOM + } +} + +function trailingEdge(dir: FlexDirection): number { + switch (dir) { + case FlexDirection.Row: + return EDGE_RIGHT + + case FlexDirection.RowReverse: + return EDGE_LEFT + + case FlexDirection.Column: + return EDGE_BOTTOM + + case FlexDirection.ColumnReverse: + return EDGE_TOP + } +} + +export type MeasureFunction = ( + width: number, + widthMode: MeasureMode, + height: number, + heightMode: MeasureMode +) => { + width: number + height: number +} +export type Size = { + width: number + height: number +} +export type Config = { + pointScaleFactor: number + errata: Errata + useWebDefaults: boolean + free(): void + isExperimentalFeatureEnabled(_: ExperimentalFeature): boolean + setExperimentalFeatureEnabled(_: ExperimentalFeature, __: boolean): void + setPointScaleFactor(factor: number): void + getErrata(): Errata + setErrata(errata: Errata): void + setUseWebDefaults(v: boolean): void +} + +function createConfig(): Config { + const config: Config = { + pointScaleFactor: 1, + errata: Errata.None, + useWebDefaults: false, + free() {}, + isExperimentalFeatureEnabled() { + return false + }, + setExperimentalFeatureEnabled() {}, + setPointScaleFactor(f) { + config.pointScaleFactor = f + }, + getErrata() { + return config.errata + }, + setErrata(e) { + config.errata = e + }, + setUseWebDefaults(v) { + config.useWebDefaults = v + } + } + + return config +} + +export class Node { + style: Style + layout: Layout + parent: Node | null + children: Node[] + measureFunc: MeasureFunction | null + config: Config + isDirty_: boolean + isReferenceBaseline_: boolean + _flexBasis = 0 + _mainSize = 0 + _crossSize = 0 + _lineIndex = 0 + _hasAutoMargin = false + _hasPosition = false + _hasPadding = false + _hasBorder = false + _hasMargin = false + _lW = NaN + _lH = NaN + _lWM: MeasureMode = 0 + _lHM: MeasureMode = 0 + _lOW = NaN + _lOH = NaN + _lFW = false + _lFH = false + _lOutW = NaN + _lOutH = NaN + _hasL = false + _mW = NaN + _mH = NaN + _mWM: MeasureMode = 0 + _mHM: MeasureMode = 0 + _mOW = NaN + _mOH = NaN + _mOutW = NaN + _mOutH = NaN + _hasM = false + _fbBasis = NaN + _fbOwnerW = NaN + _fbOwnerH = NaN + _fbAvailMain = NaN + _fbAvailCross = NaN + _fbCrossMode: MeasureMode = 0 + _fbGen = -1 + _cIn: Float64Array | null = null + _cOut: Float64Array | null = null + _cGen = -1 + _cN = 0 + _cWr = 0 + constructor(config?: Config) { + this.style = defaultStyle() + this.layout = { + left: 0, + top: 0, + width: 0, + height: 0, + border: [0, 0, 0, 0], + padding: [0, 0, 0, 0], + margin: [0, 0, 0, 0] + } + this.parent = null + this.children = [] + this.measureFunc = null + this.config = config ?? DEFAULT_CONFIG + this.isDirty_ = true + this.isReferenceBaseline_ = false + _yogaLiveNodes++ + } + insertChild(child: Node, index: number): void { + child.parent = this + this.children.splice(index, 0, child) + this.markDirty() + } + removeChild(child: Node): void { + const idx = this.children.indexOf(child) + + if (idx >= 0) { + this.children.splice(idx, 1) + child.parent = null + this.markDirty() + } + } + getChild(index: number): Node { + return this.children[index]! + } + getChildCount(): number { + return this.children.length + } + getParent(): Node | null { + return this.parent + } + free(): void { + this.parent = null + this.children = [] + this.measureFunc = null + this._cIn = null + this._cOut = null + _yogaLiveNodes-- + } + freeRecursive(): void { + for (const c of this.children) { + c.freeRecursive() + } + + this.free() + } + reset(): void { + this.style = defaultStyle() + this.children = [] + this.parent = null + this.measureFunc = null + this.isDirty_ = true + this._hasAutoMargin = false + this._hasPosition = false + this._hasPadding = false + this._hasBorder = false + this._hasMargin = false + this._hasL = false + this._hasM = false + this._cN = 0 + this._cWr = 0 + this._fbBasis = NaN + } + markDirty(): void { + this.isDirty_ = true + + if (this.parent && !this.parent.isDirty_) { + this.parent.markDirty() + } + } + isDirty(): boolean { + return this.isDirty_ + } + hasNewLayout(): boolean { + return true + } + markLayoutSeen(): void {} + setMeasureFunc(fn: MeasureFunction | null): void { + this.measureFunc = fn + this.markDirty() + } + unsetMeasureFunc(): void { + this.measureFunc = null + this.markDirty() + } + getComputedLeft(): number { + return this.layout.left + } + getComputedTop(): number { + return this.layout.top + } + getComputedWidth(): number { + return this.layout.width + } + getComputedHeight(): number { + return this.layout.height + } + getComputedRight(): number { + const p = this.parent + + return p ? p.layout.width - this.layout.left - this.layout.width : 0 + } + getComputedBottom(): number { + const p = this.parent + + return p ? p.layout.height - this.layout.top - this.layout.height : 0 + } + getComputedLayout(): { + left: number + top: number + right: number + bottom: number + width: number + height: number + } { + return { + left: this.layout.left, + top: this.layout.top, + right: this.getComputedRight(), + bottom: this.getComputedBottom(), + width: this.layout.width, + height: this.layout.height + } + } + getComputedBorder(edge: Edge): number { + return this.layout.border[physicalEdge(edge)]! + } + getComputedPadding(edge: Edge): number { + return this.layout.padding[physicalEdge(edge)]! + } + getComputedMargin(edge: Edge): number { + return this.layout.margin[physicalEdge(edge)]! + } + setWidth(v: number | 'auto' | string | undefined): void { + this.style.width = parseDimension(v) + this.markDirty() + } + setWidthPercent(v: number): void { + this.style.width = percentValue(v) + this.markDirty() + } + setWidthAuto(): void { + this.style.width = AUTO_VALUE + this.markDirty() + } + setHeight(v: number | 'auto' | string | undefined): void { + this.style.height = parseDimension(v) + this.markDirty() + } + setHeightPercent(v: number): void { + this.style.height = percentValue(v) + this.markDirty() + } + setHeightAuto(): void { + this.style.height = AUTO_VALUE + this.markDirty() + } + setMinWidth(v: number | string | undefined): void { + this.style.minWidth = parseDimension(v) + this.markDirty() + } + setMinWidthPercent(v: number): void { + this.style.minWidth = percentValue(v) + this.markDirty() + } + setMinHeight(v: number | string | undefined): void { + this.style.minHeight = parseDimension(v) + this.markDirty() + } + setMinHeightPercent(v: number): void { + this.style.minHeight = percentValue(v) + this.markDirty() + } + setMaxWidth(v: number | string | undefined): void { + this.style.maxWidth = parseDimension(v) + this.markDirty() + } + setMaxWidthPercent(v: number): void { + this.style.maxWidth = percentValue(v) + this.markDirty() + } + setMaxHeight(v: number | string | undefined): void { + this.style.maxHeight = parseDimension(v) + this.markDirty() + } + setMaxHeightPercent(v: number): void { + this.style.maxHeight = percentValue(v) + this.markDirty() + } + setFlexDirection(dir: FlexDirection): void { + this.style.flexDirection = dir + this.markDirty() + } + setFlexGrow(v: number | undefined): void { + this.style.flexGrow = v ?? 0 + this.markDirty() + } + setFlexShrink(v: number | undefined): void { + this.style.flexShrink = v ?? 0 + this.markDirty() + } + setFlex(v: number | undefined): void { + if (v === undefined || isNaN(v)) { + this.style.flexGrow = 0 + this.style.flexShrink = 0 + } else if (v > 0) { + this.style.flexGrow = v + this.style.flexShrink = 1 + this.style.flexBasis = pointValue(0) + } else if (v < 0) { + this.style.flexGrow = 0 + this.style.flexShrink = -v + } else { + this.style.flexGrow = 0 + this.style.flexShrink = 0 + } + + this.markDirty() + } + setFlexBasis(v: number | 'auto' | string | undefined): void { + this.style.flexBasis = parseDimension(v) + this.markDirty() + } + setFlexBasisPercent(v: number): void { + this.style.flexBasis = percentValue(v) + this.markDirty() + } + setFlexBasisAuto(): void { + this.style.flexBasis = AUTO_VALUE + this.markDirty() + } + setFlexWrap(wrap: Wrap): void { + this.style.flexWrap = wrap + this.markDirty() + } + setAlignItems(a: Align): void { + this.style.alignItems = a + this.markDirty() + } + setAlignSelf(a: Align): void { + this.style.alignSelf = a + this.markDirty() + } + setAlignContent(a: Align): void { + this.style.alignContent = a + this.markDirty() + } + setJustifyContent(j: Justify): void { + this.style.justifyContent = j + this.markDirty() + } + setDisplay(d: Display): void { + this.style.display = d + this.markDirty() + } + getDisplay(): Display { + return this.style.display + } + setPositionType(t: PositionType): void { + this.style.positionType = t + this.markDirty() + } + setPosition(edge: Edge, v: number | string | undefined): void { + this.style.position[edge] = parseDimension(v) + this._hasPosition = hasAnyDefinedEdge(this.style.position) + this.markDirty() + } + setPositionPercent(edge: Edge, v: number): void { + this.style.position[edge] = percentValue(v) + this._hasPosition = true + this.markDirty() + } + setPositionAuto(edge: Edge): void { + this.style.position[edge] = AUTO_VALUE + this._hasPosition = true + this.markDirty() + } + setOverflow(o: Overflow): void { + this.style.overflow = o + this.markDirty() + } + setDirection(d: Direction): void { + this.style.direction = d + this.markDirty() + } + setBoxSizing(_: BoxSizing): void {} + setMargin(edge: Edge, v: number | 'auto' | string | undefined): void { + const val = parseDimension(v) + this.style.margin[edge] = val + + if (val.unit === Unit.Auto) { + this._hasAutoMargin = true + } else { + this._hasAutoMargin = hasAnyAutoEdge(this.style.margin) + } + + this._hasMargin = this._hasAutoMargin || hasAnyDefinedEdge(this.style.margin) + this.markDirty() + } + setMarginPercent(edge: Edge, v: number): void { + this.style.margin[edge] = percentValue(v) + this._hasAutoMargin = hasAnyAutoEdge(this.style.margin) + this._hasMargin = true + this.markDirty() + } + setMarginAuto(edge: Edge): void { + this.style.margin[edge] = AUTO_VALUE + this._hasAutoMargin = true + this._hasMargin = true + this.markDirty() + } + setPadding(edge: Edge, v: number | string | undefined): void { + this.style.padding[edge] = parseDimension(v) + this._hasPadding = hasAnyDefinedEdge(this.style.padding) + this.markDirty() + } + setPaddingPercent(edge: Edge, v: number): void { + this.style.padding[edge] = percentValue(v) + this._hasPadding = true + this.markDirty() + } + setBorder(edge: Edge, v: number | undefined): void { + this.style.border[edge] = v === undefined ? UNDEFINED_VALUE : pointValue(v) + this._hasBorder = hasAnyDefinedEdge(this.style.border) + this.markDirty() + } + setGap(gutter: Gutter, v: number | string | undefined): void { + this.style.gap[gutter] = parseDimension(v) + this.markDirty() + } + setGapPercent(gutter: Gutter, v: number): void { + this.style.gap[gutter] = percentValue(v) + this.markDirty() + } + getFlexDirection(): FlexDirection { + return this.style.flexDirection + } + getJustifyContent(): Justify { + return this.style.justifyContent + } + getAlignItems(): Align { + return this.style.alignItems + } + getAlignSelf(): Align { + return this.style.alignSelf + } + getAlignContent(): Align { + return this.style.alignContent + } + getFlexGrow(): number { + return this.style.flexGrow + } + getFlexShrink(): number { + return this.style.flexShrink + } + getFlexBasis(): Value { + return this.style.flexBasis + } + getFlexWrap(): Wrap { + return this.style.flexWrap + } + getWidth(): Value { + return this.style.width + } + getHeight(): Value { + return this.style.height + } + getOverflow(): Overflow { + return this.style.overflow + } + getPositionType(): PositionType { + return this.style.positionType + } + getDirection(): Direction { + return this.style.direction + } + copyStyle(_: Node): void {} + setDirtiedFunc(_: unknown): void {} + unsetDirtiedFunc(): void {} + setIsReferenceBaseline(v: boolean): void { + this.isReferenceBaseline_ = v + this.markDirty() + } + isReferenceBaseline(): boolean { + return this.isReferenceBaseline_ + } + setAspectRatio(_: number | undefined): void {} + getAspectRatio(): number { + return NaN + } + setAlwaysFormsContainingBlock(_: boolean): void {} + calculateLayout(ownerWidth: number | undefined, ownerHeight: number | undefined, _direction?: Direction): void { + _yogaNodesVisited = 0 + _yogaMeasureCalls = 0 + _yogaCacheHits = 0 + _generation++ + const w = ownerWidth === undefined ? NaN : ownerWidth + const h = ownerHeight === undefined ? NaN : ownerHeight + layoutNode( + this, + w, + h, + isDefined(w) ? MeasureMode.Exactly : MeasureMode.Undefined, + isDefined(h) ? MeasureMode.Exactly : MeasureMode.Undefined, + w, + h, + true + ) + const mar = this.layout.margin + const posL = resolveValue(resolveEdgeRaw(this.style.position, EDGE_LEFT), isDefined(w) ? w : 0) + const posT = resolveValue(resolveEdgeRaw(this.style.position, EDGE_TOP), isDefined(w) ? w : 0) + this.layout.left = mar[EDGE_LEFT] + (isDefined(posL) ? posL : 0) + this.layout.top = mar[EDGE_TOP] + (isDefined(posT) ? posT : 0) + roundLayout(this, this.config.pointScaleFactor, 0, 0) + } +} +const DEFAULT_CONFIG = createConfig() +const CACHE_SLOTS = 4 + +function cacheWrite( + node: Node, + aW: number, + aH: number, + wM: MeasureMode, + hM: MeasureMode, + oW: number, + oH: number, + fW: boolean, + fH: boolean, + wasDirty: boolean +): void { + if (!node._cIn) { + node._cIn = new Float64Array(CACHE_SLOTS * 8) + node._cOut = new Float64Array(CACHE_SLOTS * 2) + } + + if (wasDirty && node._cGen !== _generation) { + node._cN = 0 + node._cWr = 0 + } + + const i = node._cWr++ % CACHE_SLOTS + + if (node._cN < CACHE_SLOTS) { + node._cN = node._cWr + } + + const o = i * 8 + const cIn = node._cIn + cIn[o] = aW + cIn[o + 1] = aH + cIn[o + 2] = wM + cIn[o + 3] = hM + cIn[o + 4] = oW + cIn[o + 5] = oH + cIn[o + 6] = fW ? 1 : 0 + cIn[o + 7] = fH ? 1 : 0 + node._cOut![i * 2] = node.layout.width + node._cOut![i * 2 + 1] = node.layout.height + node._cGen = _generation +} + +function commitCacheOutputs(node: Node, performLayout: boolean): void { + if (performLayout) { + node._lOutW = node.layout.width + node._lOutH = node.layout.height + } else { + node._mOutW = node.layout.width + node._mOutH = node.layout.height + } +} + +let _generation = 0 +let _yogaNodesVisited = 0 +let _yogaMeasureCalls = 0 +let _yogaCacheHits = 0 +let _yogaLiveNodes = 0 + +export function getYogaCounters(): { + visited: number + measured: number + cacheHits: number + live: number +} { + return { + visited: _yogaNodesVisited, + measured: _yogaMeasureCalls, + cacheHits: _yogaCacheHits, + live: _yogaLiveNodes + } +} + +function layoutNode( + node: Node, + availableWidth: number, + availableHeight: number, + widthMode: MeasureMode, + heightMode: MeasureMode, + ownerWidth: number, + ownerHeight: number, + performLayout: boolean, + forceWidth = false, + forceHeight = false +): void { + _yogaNodesVisited++ + const style = node.style + const layout = node.layout + const sameGen = node._cGen === _generation && !performLayout + + if (!node.isDirty_ || sameGen) { + if ( + !node.isDirty_ && + node._hasL && + node._lWM === widthMode && + node._lHM === heightMode && + node._lFW === forceWidth && + node._lFH === forceHeight && + sameFloat(node._lW, availableWidth) && + sameFloat(node._lH, availableHeight) && + sameFloat(node._lOW, ownerWidth) && + sameFloat(node._lOH, ownerHeight) + ) { + _yogaCacheHits++ + layout.width = node._lOutW + layout.height = node._lOutH + + return + } + + if (node._cN > 0 && (sameGen || !node.isDirty_)) { + const cIn = node._cIn! + + for (let i = 0; i < node._cN; i++) { + const o = i * 8 + + if ( + cIn[o + 2] === widthMode && + cIn[o + 3] === heightMode && + cIn[o + 6] === (forceWidth ? 1 : 0) && + cIn[o + 7] === (forceHeight ? 1 : 0) && + sameFloat(cIn[o]!, availableWidth) && + sameFloat(cIn[o + 1]!, availableHeight) && + sameFloat(cIn[o + 4]!, ownerWidth) && + sameFloat(cIn[o + 5]!, ownerHeight) + ) { + layout.width = node._cOut![i * 2]! + layout.height = node._cOut![i * 2 + 1]! + _yogaCacheHits++ + + return + } + } + } + + if ( + !node.isDirty_ && + !performLayout && + node._hasM && + node._mWM === widthMode && + node._mHM === heightMode && + sameFloat(node._mW, availableWidth) && + sameFloat(node._mH, availableHeight) && + sameFloat(node._mOW, ownerWidth) && + sameFloat(node._mOH, ownerHeight) + ) { + layout.width = node._mOutW + layout.height = node._mOutH + _yogaCacheHits++ + + return + } + } + + const wasDirty = node.isDirty_ + + if (performLayout) { + node._lW = availableWidth + node._lH = availableHeight + node._lWM = widthMode + node._lHM = heightMode + node._lOW = ownerWidth + node._lOH = ownerHeight + node._lFW = forceWidth + node._lFH = forceHeight + node._hasL = true + node.isDirty_ = false + + if (wasDirty) { + node._hasM = false + } + } else { + node._mW = availableWidth + node._mH = availableHeight + node._mWM = widthMode + node._mHM = heightMode + node._mOW = ownerWidth + node._mOH = ownerHeight + node._hasM = true + + if (wasDirty) { + node._hasL = false + } + } + + const pad = layout.padding + const bor = layout.border + const mar = layout.margin + + if (node._hasPadding) { + resolveEdges4Into(style.padding, ownerWidth, pad) + } else { + pad[0] = pad[1] = pad[2] = pad[3] = 0 + } + + if (node._hasBorder) { + resolveEdges4Into(style.border, ownerWidth, bor) + } else { + bor[0] = bor[1] = bor[2] = bor[3] = 0 + } + + if (node._hasMargin) { + resolveEdges4Into(style.margin, ownerWidth, mar) + } else { + mar[0] = mar[1] = mar[2] = mar[3] = 0 + } + + const paddingBorderWidth = pad[0] + pad[2] + bor[0] + bor[2] + const paddingBorderHeight = pad[1] + pad[3] + bor[1] + bor[3] + const styleWidth = forceWidth ? NaN : resolveValue(style.width, ownerWidth) + + const styleHeight = forceHeight ? NaN : resolveValue(style.height, ownerHeight) + + let width = availableWidth + let height = availableHeight + let wMode = widthMode + let hMode = heightMode + + if (isDefined(styleWidth)) { + width = styleWidth + wMode = MeasureMode.Exactly + } + + if (isDefined(styleHeight)) { + height = styleHeight + hMode = MeasureMode.Exactly + } + + width = boundAxis(style, true, width, ownerWidth, ownerHeight) + height = boundAxis(style, false, height, ownerWidth, ownerHeight) + + if (node.measureFunc && node.children.length === 0) { + const innerW = wMode === MeasureMode.Undefined ? NaN : Math.max(0, width - paddingBorderWidth) + + const innerH = hMode === MeasureMode.Undefined ? NaN : Math.max(0, height - paddingBorderHeight) + + _yogaMeasureCalls++ + const measured = node.measureFunc(innerW, wMode, innerH, hMode) + node.layout.width = + wMode === MeasureMode.Exactly + ? width + : boundAxis(style, true, (measured.width ?? 0) + paddingBorderWidth, ownerWidth, ownerHeight) + node.layout.height = + hMode === MeasureMode.Exactly + ? height + : boundAxis(style, false, (measured.height ?? 0) + paddingBorderHeight, ownerWidth, ownerHeight) + commitCacheOutputs(node, performLayout) + cacheWrite( + node, + availableWidth, + availableHeight, + widthMode, + heightMode, + ownerWidth, + ownerHeight, + forceWidth, + forceHeight, + wasDirty + ) + + return + } + + if (node.children.length === 0) { + node.layout.width = + wMode === MeasureMode.Exactly ? width : boundAxis(style, true, paddingBorderWidth, ownerWidth, ownerHeight) + node.layout.height = + hMode === MeasureMode.Exactly ? height : boundAxis(style, false, paddingBorderHeight, ownerWidth, ownerHeight) + commitCacheOutputs(node, performLayout) + cacheWrite( + node, + availableWidth, + availableHeight, + widthMode, + heightMode, + ownerWidth, + ownerHeight, + forceWidth, + forceHeight, + wasDirty + ) + + return + } + + const mainAxis = style.flexDirection + const crossAx = crossAxis(mainAxis) + const isMainRow = isRow(mainAxis) + const mainSize = isMainRow ? width : height + const crossSize = isMainRow ? height : width + const mainMode = isMainRow ? wMode : hMode + const crossMode = isMainRow ? hMode : wMode + const mainPadBorder = isMainRow ? paddingBorderWidth : paddingBorderHeight + const crossPadBorder = isMainRow ? paddingBorderHeight : paddingBorderWidth + + const innerMainSize = isDefined(mainSize) ? Math.max(0, mainSize - mainPadBorder) : NaN + + const innerCrossSize = isDefined(crossSize) ? Math.max(0, crossSize - crossPadBorder) : NaN + + const gapMain = resolveGap(style, isMainRow ? Gutter.Column : Gutter.Row, innerMainSize) + const flowChildren: Node[] = [] + const absChildren: Node[] = [] + collectLayoutChildren(node, flowChildren, absChildren) + const ownerW = isDefined(width) ? width : NaN + const ownerH = isDefined(height) ? height : NaN + const isWrap = style.flexWrap !== Wrap.NoWrap + const gapCross = resolveGap(style, isMainRow ? Gutter.Row : Gutter.Column, innerCrossSize) + + for (const c of flowChildren) { + c._flexBasis = computeFlexBasis(c, mainAxis, innerMainSize, innerCrossSize, crossMode, ownerW, ownerH) + } + + const lines: Node[][] = [] + + if (!isWrap || !isDefined(innerMainSize) || flowChildren.length === 0) { + for (const c of flowChildren) { + c._lineIndex = 0 + } + + lines.push(flowChildren) + } else { + let lineStart = 0 + let lineLen = 0 + + for (let i = 0; i < flowChildren.length; i++) { + const c = flowChildren[i]! + const hypo = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH) + const outer = Math.max(0, hypo) + childMarginForAxis(c, mainAxis, ownerW) + const withGap = i > lineStart ? gapMain : 0 + + if (i > lineStart && lineLen + withGap + outer > innerMainSize) { + lines.push(flowChildren.slice(lineStart, i)) + lineStart = i + lineLen = outer + } else { + lineLen += withGap + outer + } + + c._lineIndex = lines.length + } + + lines.push(flowChildren.slice(lineStart)) + } + + const lineCount = lines.length + const isBaseline = isBaselineLayout(node, flowChildren) + const lineConsumedMain: number[] = new Array(lineCount) + const lineCrossSizes: number[] = new Array(lineCount) + const lineMaxAscent: number[] = isBaseline ? new Array(lineCount).fill(0) : [] + let maxLineMain = 0 + let totalLinesCross = 0 + + for (let li = 0; li < lineCount; li++) { + const line = lines[li]! + const lineGap = line.length > 1 ? gapMain * (line.length - 1) : 0 + let lineBasis = lineGap + + for (const c of line) { + lineBasis += c._flexBasis + childMarginForAxis(c, mainAxis, ownerW) + } + + let availMain = innerMainSize + + if (!isDefined(availMain)) { + const mainOwner = isMainRow ? ownerWidth : ownerHeight + const minM = resolveValue(isMainRow ? style.minWidth : style.minHeight, mainOwner) + const maxM = resolveValue(isMainRow ? style.maxWidth : style.maxHeight, mainOwner) + + if (isDefined(maxM) && lineBasis > maxM - mainPadBorder) { + availMain = Math.max(0, maxM - mainPadBorder) + } else if (isDefined(minM) && lineBasis < minM - mainPadBorder) { + availMain = Math.max(0, minM - mainPadBorder) + } + } + + resolveFlexibleLengths(line, availMain, lineBasis, isMainRow, ownerW, ownerH) + let lineCross = 0 + + for (const c of line) { + const cStyle = c.style + const childAlign = cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf + const cMarginCross = childMarginForAxis(c, crossAx, ownerW) + let childCrossSize = NaN + let childCrossMode: MeasureMode = MeasureMode.Undefined + const resolvedCrossStyle = resolveValue(isMainRow ? cStyle.height : cStyle.width, isMainRow ? ownerH : ownerW) + const crossLeadE = isMainRow ? EDGE_TOP : EDGE_LEFT + const crossTrailE = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT + + const hasCrossAutoMargin = + c._hasAutoMargin && (isMarginAuto(cStyle.margin, crossLeadE) || isMarginAuto(cStyle.margin, crossTrailE)) + + if (isDefined(resolvedCrossStyle)) { + childCrossSize = resolvedCrossStyle + childCrossMode = MeasureMode.Exactly + } else if ( + childAlign === Align.Stretch && + !hasCrossAutoMargin && + !isWrap && + isDefined(innerCrossSize) && + crossMode === MeasureMode.Exactly + ) { + childCrossSize = Math.max(0, innerCrossSize - cMarginCross) + childCrossMode = MeasureMode.Exactly + } else if (!isWrap && isDefined(innerCrossSize)) { + childCrossSize = Math.max(0, innerCrossSize - cMarginCross) + childCrossMode = MeasureMode.AtMost + } + + const cw = isMainRow ? c._mainSize : childCrossSize + const ch = isMainRow ? childCrossSize : c._mainSize + layoutNode( + c, + cw, + ch, + isMainRow ? MeasureMode.Exactly : childCrossMode, + isMainRow ? childCrossMode : MeasureMode.Exactly, + ownerW, + ownerH, + performLayout, + isMainRow, + !isMainRow + ) + c._crossSize = isMainRow ? c.layout.height : c.layout.width + lineCross = Math.max(lineCross, c._crossSize + cMarginCross) + } + + if (isBaseline) { + let maxAscent = 0 + let maxDescent = 0 + + for (const c of line) { + if (resolveChildAlign(node, c) !== Align.Baseline) { + continue + } + + const mTop = resolveEdge(c.style.margin, EDGE_TOP, ownerW) + const mBot = resolveEdge(c.style.margin, EDGE_BOTTOM, ownerW) + const ascent = calculateBaseline(c) + mTop + const descent = c.layout.height + mTop + mBot - ascent + + if (ascent > maxAscent) { + maxAscent = ascent + } + + if (descent > maxDescent) { + maxDescent = descent + } + } + + lineMaxAscent[li] = maxAscent + + if (maxAscent + maxDescent > lineCross) { + lineCross = maxAscent + maxDescent + } + } + + const mainLead = leadingEdge(mainAxis) + const mainTrail = trailingEdge(mainAxis) + let consumed = lineGap + + for (const c of line) { + const cm = c.layout.margin + consumed += c._mainSize + cm[mainLead]! + cm[mainTrail]! + } + + lineConsumedMain[li] = consumed + lineCrossSizes[li] = lineCross + maxLineMain = Math.max(maxLineMain, consumed) + totalLinesCross += lineCross + } + + const totalCrossGap = lineCount > 1 ? gapCross * (lineCount - 1) : 0 + totalLinesCross += totalCrossGap + const isScroll = style.overflow === Overflow.Scroll + const contentMain = maxLineMain + mainPadBorder + + const finalMainSize = + mainMode === MeasureMode.Exactly + ? mainSize + : mainMode === MeasureMode.AtMost && isScroll + ? Math.max(Math.min(mainSize, contentMain), mainPadBorder) + : isWrap && lineCount > 1 && mainMode === MeasureMode.AtMost + ? mainSize + : contentMain + + const contentCross = totalLinesCross + crossPadBorder + + const finalCrossSize = + crossMode === MeasureMode.Exactly + ? crossSize + : crossMode === MeasureMode.AtMost && isScroll + ? Math.max(Math.min(crossSize, contentCross), crossPadBorder) + : contentCross + + node.layout.width = boundAxis(style, true, isMainRow ? finalMainSize : finalCrossSize, ownerWidth, ownerHeight) + node.layout.height = boundAxis(style, false, isMainRow ? finalCrossSize : finalMainSize, ownerWidth, ownerHeight) + commitCacheOutputs(node, performLayout) + cacheWrite( + node, + availableWidth, + availableHeight, + widthMode, + heightMode, + ownerWidth, + ownerHeight, + forceWidth, + forceHeight, + wasDirty + ) + + if (!performLayout) { + return + } + + const actualInnerMain = (isMainRow ? node.layout.width : node.layout.height) - mainPadBorder + const actualInnerCross = (isMainRow ? node.layout.height : node.layout.width) - crossPadBorder + const mainLeadEdgePhys = leadingEdge(mainAxis) + const mainTrailEdgePhys = trailingEdge(mainAxis) + const crossLeadEdgePhys = isMainRow ? EDGE_TOP : EDGE_LEFT + const crossTrailEdgePhys = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT + const reversed = isReverse(mainAxis) + const mainContainerSize = isMainRow ? node.layout.width : node.layout.height + const crossLead = pad[crossLeadEdgePhys]! + bor[crossLeadEdgePhys]! + let lineCrossOffset = crossLead + let betweenLines = gapCross + const freeCross = actualInnerCross - totalLinesCross + + if (lineCount === 1 && !isWrap && !isBaseline) { + lineCrossSizes[0] = actualInnerCross + } else { + const remCross = Math.max(0, freeCross) + + switch (style.alignContent) { + case Align.FlexStart: + break + + case Align.Center: + lineCrossOffset += freeCross / 2 + + break + + case Align.FlexEnd: + lineCrossOffset += freeCross + + break + + case Align.Stretch: + if (lineCount > 0 && remCross > 0) { + const add = remCross / lineCount + + for (let i = 0; i < lineCount; i++) { + lineCrossSizes[i]! += add + } + } + + break + + case Align.SpaceBetween: + if (lineCount > 1) { + betweenLines += remCross / (lineCount - 1) + } + + break + + case Align.SpaceAround: + if (lineCount > 0) { + betweenLines += remCross / lineCount + lineCrossOffset += remCross / lineCount / 2 + } + + break + + case Align.SpaceEvenly: + if (lineCount > 0) { + betweenLines += remCross / (lineCount + 1) + lineCrossOffset += remCross / (lineCount + 1) + } + + break + + default: + break + } + } + + const wrapReverse = style.flexWrap === Wrap.WrapReverse + const crossContainerSize = isMainRow ? node.layout.height : node.layout.width + let lineCrossPos = lineCrossOffset + + for (let li = 0; li < lineCount; li++) { + const line = lines[li]! + const lineCross = lineCrossSizes[li]! + const consumedMain = lineConsumedMain[li]! + const n = line.length + + if (isWrap || crossMode !== MeasureMode.Exactly) { + for (const c of line) { + const cStyle = c.style + const childAlign = cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf + + const crossStyleDef = isDefined( + resolveValue(isMainRow ? cStyle.height : cStyle.width, isMainRow ? ownerH : ownerW) + ) + + const hasCrossAutoMargin = + c._hasAutoMargin && + (isMarginAuto(cStyle.margin, crossLeadEdgePhys) || isMarginAuto(cStyle.margin, crossTrailEdgePhys)) + + if (childAlign === Align.Stretch && !crossStyleDef && !hasCrossAutoMargin) { + const cMarginCross = childMarginForAxis(c, crossAx, ownerW) + const target = Math.max(0, lineCross - cMarginCross) + + if (c._crossSize !== target) { + const cw = isMainRow ? c._mainSize : target + const ch = isMainRow ? target : c._mainSize + layoutNode( + c, + cw, + ch, + MeasureMode.Exactly, + MeasureMode.Exactly, + ownerW, + ownerH, + performLayout, + isMainRow, + !isMainRow + ) + c._crossSize = target + } + } + } + } + + let mainOffset = pad[mainLeadEdgePhys]! + bor[mainLeadEdgePhys]! + let betweenMain = gapMain + let numAutoMarginsMain = 0 + + for (const c of line) { + if (!c._hasAutoMargin) { + continue + } + + if (isMarginAuto(c.style.margin, mainLeadEdgePhys)) { + numAutoMarginsMain++ + } + + if (isMarginAuto(c.style.margin, mainTrailEdgePhys)) { + numAutoMarginsMain++ + } + } + + const freeMain = actualInnerMain - consumedMain + const remainingMain = Math.max(0, freeMain) + + const autoMarginMainSize = numAutoMarginsMain > 0 && remainingMain > 0 ? remainingMain / numAutoMarginsMain : 0 + + if (numAutoMarginsMain === 0) { + switch (style.justifyContent) { + case Justify.FlexStart: + break + + case Justify.Center: + mainOffset += freeMain / 2 + + break + + case Justify.FlexEnd: + mainOffset += freeMain + + break + + case Justify.SpaceBetween: + if (n > 1) { + betweenMain += remainingMain / (n - 1) + } + + break + + case Justify.SpaceAround: + if (n > 0) { + betweenMain += remainingMain / n + mainOffset += remainingMain / n / 2 + } + + break + + case Justify.SpaceEvenly: + if (n > 0) { + betweenMain += remainingMain / (n + 1) + mainOffset += remainingMain / (n + 1) + } + + break + } + } + + const effectiveLineCrossPos = wrapReverse ? crossContainerSize - lineCrossPos - lineCross : lineCrossPos + + let pos = mainOffset + + for (const c of line) { + const cMargin = c.style.margin + const cLayoutMargin = c.layout.margin + let autoMainLead = false + let autoMainTrail = false + let autoCrossLead = false + let autoCrossTrail = false + let mMainLead: number + let mMainTrail: number + let mCrossLead: number + let mCrossTrail: number + + if (c._hasAutoMargin) { + autoMainLead = isMarginAuto(cMargin, mainLeadEdgePhys) + autoMainTrail = isMarginAuto(cMargin, mainTrailEdgePhys) + autoCrossLead = isMarginAuto(cMargin, crossLeadEdgePhys) + autoCrossTrail = isMarginAuto(cMargin, crossTrailEdgePhys) + mMainLead = autoMainLead ? autoMarginMainSize : cLayoutMargin[mainLeadEdgePhys]! + mMainTrail = autoMainTrail ? autoMarginMainSize : cLayoutMargin[mainTrailEdgePhys]! + mCrossLead = autoCrossLead ? 0 : cLayoutMargin[crossLeadEdgePhys]! + mCrossTrail = autoCrossTrail ? 0 : cLayoutMargin[crossTrailEdgePhys]! + } else { + mMainLead = cLayoutMargin[mainLeadEdgePhys]! + mMainTrail = cLayoutMargin[mainTrailEdgePhys]! + mCrossLead = cLayoutMargin[crossLeadEdgePhys]! + mCrossTrail = cLayoutMargin[crossTrailEdgePhys]! + } + + const mainPos = reversed ? mainContainerSize - (pos + mMainLead) - c._mainSize : pos + mMainLead + + const childAlign = c.style.alignSelf === Align.Auto ? style.alignItems : c.style.alignSelf + let crossPos = effectiveLineCrossPos + mCrossLead + const crossFree = lineCross - c._crossSize - mCrossLead - mCrossTrail + + if (autoCrossLead && autoCrossTrail) { + crossPos += Math.max(0, crossFree) / 2 + } else if (autoCrossLead) { + crossPos += Math.max(0, crossFree) + } else if (autoCrossTrail) { + } else { + switch (childAlign) { + case Align.FlexStart: + + case Align.Stretch: + if (wrapReverse) { + crossPos += crossFree + } + + break + + case Align.Center: + crossPos += crossFree / 2 + + break + + case Align.FlexEnd: + if (!wrapReverse) { + crossPos += crossFree + } + + break + + case Align.Baseline: + if (isBaseline) { + crossPos = effectiveLineCrossPos + lineMaxAscent[li]! - calculateBaseline(c) + } + + break + + default: + break + } + } + + let relX = 0 + let relY = 0 + + if (c._hasPosition) { + const relLeft = resolveValue(resolveEdgeRaw(c.style.position, EDGE_LEFT), ownerW) + const relRight = resolveValue(resolveEdgeRaw(c.style.position, EDGE_RIGHT), ownerW) + const relTop = resolveValue(resolveEdgeRaw(c.style.position, EDGE_TOP), ownerW) + const relBottom = resolveValue(resolveEdgeRaw(c.style.position, EDGE_BOTTOM), ownerW) + relX = isDefined(relLeft) ? relLeft : isDefined(relRight) ? -relRight : 0 + relY = isDefined(relTop) ? relTop : isDefined(relBottom) ? -relBottom : 0 + } + + if (isMainRow) { + c.layout.left = mainPos + relX + c.layout.top = crossPos + relY + } else { + c.layout.left = crossPos + relX + c.layout.top = mainPos + relY + } + + pos += c._mainSize + mMainLead + mMainTrail + betweenMain + } + + lineCrossPos += lineCross + betweenLines + } + + for (const c of absChildren) { + layoutAbsoluteChild(node, c, node.layout.width, node.layout.height, pad, bor) + } +} + +function layoutAbsoluteChild( + parent: Node, + child: Node, + parentWidth: number, + parentHeight: number, + pad: [number, number, number, number], + bor: [number, number, number, number] +): void { + const cs = child.style + const posLeft = resolveEdgeRaw(cs.position, EDGE_LEFT) + const posRight = resolveEdgeRaw(cs.position, EDGE_RIGHT) + const posTop = resolveEdgeRaw(cs.position, EDGE_TOP) + const posBottom = resolveEdgeRaw(cs.position, EDGE_BOTTOM) + const rLeft = resolveValue(posLeft, parentWidth) + const rRight = resolveValue(posRight, parentWidth) + const rTop = resolveValue(posTop, parentHeight) + const rBottom = resolveValue(posBottom, parentHeight) + const paddingBoxW = parentWidth - bor[0] - bor[2] + const paddingBoxH = parentHeight - bor[1] - bor[3] + let cw = resolveValue(cs.width, paddingBoxW) + let ch = resolveValue(cs.height, paddingBoxH) + + if (!isDefined(cw) && isDefined(rLeft) && isDefined(rRight)) { + cw = paddingBoxW - rLeft - rRight + } + + if (!isDefined(ch) && isDefined(rTop) && isDefined(rBottom)) { + ch = paddingBoxH - rTop - rBottom + } + + layoutNode( + child, + cw, + ch, + isDefined(cw) ? MeasureMode.Exactly : MeasureMode.Undefined, + isDefined(ch) ? MeasureMode.Exactly : MeasureMode.Undefined, + paddingBoxW, + paddingBoxH, + true + ) + const mL = resolveEdge(cs.margin, EDGE_LEFT, parentWidth) + const mT = resolveEdge(cs.margin, EDGE_TOP, parentWidth) + const mR = resolveEdge(cs.margin, EDGE_RIGHT, parentWidth) + const mB = resolveEdge(cs.margin, EDGE_BOTTOM, parentWidth) + const mainAxis = parent.style.flexDirection + const reversed = isReverse(mainAxis) + const mainRow = isRow(mainAxis) + const wrapReverse = parent.style.flexWrap === Wrap.WrapReverse + const alignment = cs.alignSelf === Align.Auto ? parent.style.alignItems : cs.alignSelf + let left: number + + if (isDefined(rLeft)) { + left = bor[0] + rLeft + mL + } else if (isDefined(rRight)) { + left = parentWidth - bor[2] - rRight - child.layout.width - mR + } else if (mainRow) { + const lead = pad[0] + bor[0] + const trail = parentWidth - pad[2] - bor[2] + left = reversed + ? trail - child.layout.width - mR + : justifyAbsolute(parent.style.justifyContent, lead, trail, child.layout.width) + mL + } else { + left = + alignAbsolute(alignment, pad[0] + bor[0], parentWidth - pad[2] - bor[2], child.layout.width, wrapReverse) + mL + } + + let top: number + + if (isDefined(rTop)) { + top = bor[1] + rTop + mT + } else if (isDefined(rBottom)) { + top = parentHeight - bor[3] - rBottom - child.layout.height - mB + } else if (mainRow) { + top = + alignAbsolute(alignment, pad[1] + bor[1], parentHeight - pad[3] - bor[3], child.layout.height, wrapReverse) + mT + } else { + const lead = pad[1] + bor[1] + const trail = parentHeight - pad[3] - bor[3] + top = reversed + ? trail - child.layout.height - mB + : justifyAbsolute(parent.style.justifyContent, lead, trail, child.layout.height) + mT + } + + child.layout.left = left + child.layout.top = top +} + +function justifyAbsolute(justify: Justify, leadEdge: number, trailEdge: number, childSize: number): number { + switch (justify) { + case Justify.Center: + return leadEdge + (trailEdge - leadEdge - childSize) / 2 + + case Justify.FlexEnd: + return trailEdge - childSize + + default: + return leadEdge + } +} + +function alignAbsolute( + align: Align, + leadEdge: number, + trailEdge: number, + childSize: number, + wrapReverse: boolean +): number { + switch (align) { + case Align.Center: + return leadEdge + (trailEdge - leadEdge - childSize) / 2 + + case Align.FlexEnd: + return wrapReverse ? leadEdge : trailEdge - childSize + + default: + return wrapReverse ? trailEdge - childSize : leadEdge + } +} + +function computeFlexBasis( + child: Node, + mainAxis: FlexDirection, + availableMain: number, + availableCross: number, + crossMode: MeasureMode, + ownerWidth: number, + ownerHeight: number +): number { + const sameGen = child._fbGen === _generation + + if ( + (sameGen || !child.isDirty_) && + child._fbCrossMode === crossMode && + sameFloat(child._fbOwnerW, ownerWidth) && + sameFloat(child._fbOwnerH, ownerHeight) && + sameFloat(child._fbAvailMain, availableMain) && + sameFloat(child._fbAvailCross, availableCross) + ) { + return child._fbBasis + } + + const cs = child.style + const isMainRow = isRow(mainAxis) + const basis = resolveValue(cs.flexBasis, availableMain) + + if (isDefined(basis)) { + const b = Math.max(0, basis) + child._fbBasis = b + child._fbOwnerW = ownerWidth + child._fbOwnerH = ownerHeight + child._fbAvailMain = availableMain + child._fbAvailCross = availableCross + child._fbCrossMode = crossMode + child._fbGen = _generation + + return b + } + + const mainStyleDim = isMainRow ? cs.width : cs.height + const mainOwner = isMainRow ? ownerWidth : ownerHeight + const resolved = resolveValue(mainStyleDim, mainOwner) + + if (isDefined(resolved)) { + const b = Math.max(0, resolved) + child._fbBasis = b + child._fbOwnerW = ownerWidth + child._fbOwnerH = ownerHeight + child._fbAvailMain = availableMain + child._fbAvailCross = availableCross + child._fbCrossMode = crossMode + child._fbGen = _generation + + return b + } + + const crossStyleDim = isMainRow ? cs.height : cs.width + const crossOwner = isMainRow ? ownerHeight : ownerWidth + let crossConstraint = resolveValue(crossStyleDim, crossOwner) + + let crossConstraintMode: MeasureMode = isDefined(crossConstraint) ? MeasureMode.Exactly : MeasureMode.Undefined + + if (!isDefined(crossConstraint) && isDefined(availableCross)) { + crossConstraint = availableCross + crossConstraintMode = + crossMode === MeasureMode.Exactly && isStretchAlign(child) ? MeasureMode.Exactly : MeasureMode.AtMost + } + + let mainConstraint = NaN + let mainConstraintMode: MeasureMode = MeasureMode.Undefined + + if (isMainRow && isDefined(availableMain) && hasMeasureFuncInSubtree(child)) { + mainConstraint = availableMain + mainConstraintMode = MeasureMode.AtMost + } + + const mw = isMainRow ? mainConstraint : crossConstraint + const mh = isMainRow ? crossConstraint : mainConstraint + const mwMode = isMainRow ? mainConstraintMode : crossConstraintMode + const mhMode = isMainRow ? crossConstraintMode : mainConstraintMode + layoutNode(child, mw, mh, mwMode, mhMode, ownerWidth, ownerHeight, false) + const b = isMainRow ? child.layout.width : child.layout.height + child._fbBasis = b + child._fbOwnerW = ownerWidth + child._fbOwnerH = ownerHeight + child._fbAvailMain = availableMain + child._fbAvailCross = availableCross + child._fbCrossMode = crossMode + child._fbGen = _generation + + return b +} + +function hasMeasureFuncInSubtree(node: Node): boolean { + if (node.measureFunc) { + return true + } + + for (const c of node.children) { + if (hasMeasureFuncInSubtree(c)) { + return true + } + } + + return false +} + +function resolveFlexibleLengths( + children: Node[], + availableInnerMain: number, + totalFlexBasis: number, + isMainRow: boolean, + ownerW: number, + ownerH: number +): void { + const n = children.length + const frozen: boolean[] = new Array(n).fill(false) + + const initialFree = isDefined(availableInnerMain) ? availableInnerMain - totalFlexBasis : 0 + + for (let i = 0; i < n; i++) { + const c = children[i]! + const clamped = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH) + + const inflexible = + !isDefined(availableInnerMain) || (initialFree >= 0 ? c.style.flexGrow === 0 : c.style.flexShrink === 0) + + if (inflexible) { + c._mainSize = Math.max(0, clamped) + frozen[i] = true + } else { + c._mainSize = c._flexBasis + } + } + + const unclamped: number[] = new Array(n) + + for (let iter = 0; iter <= n; iter++) { + let frozenDelta = 0 + let totalGrow = 0 + let totalShrinkScaled = 0 + let unfrozenCount = 0 + + for (let i = 0; i < n; i++) { + const c = children[i]! + + if (frozen[i]) { + frozenDelta += c._mainSize - c._flexBasis + } else { + totalGrow += c.style.flexGrow + totalShrinkScaled += c.style.flexShrink * c._flexBasis + unfrozenCount++ + } + } + + if (unfrozenCount === 0) { + break + } + + let remaining = initialFree - frozenDelta + + if (remaining > 0 && totalGrow > 0 && totalGrow < 1) { + const scaled = initialFree * totalGrow + + if (scaled < remaining) { + remaining = scaled + } + } else if (remaining < 0 && totalShrinkScaled > 0) { + let totalShrink = 0 + + for (let i = 0; i < n; i++) { + if (!frozen[i]) { + totalShrink += children[i]!.style.flexShrink + } + } + + if (totalShrink < 1) { + const scaled = initialFree * totalShrink + + if (scaled > remaining) { + remaining = scaled + } + } + } + + let totalViolation = 0 + + for (let i = 0; i < n; i++) { + if (frozen[i]) { + continue + } + + const c = children[i]! + let t = c._flexBasis + + if (remaining > 0 && totalGrow > 0) { + t += (remaining * c.style.flexGrow) / totalGrow + } else if (remaining < 0 && totalShrinkScaled > 0) { + t += (remaining * (c.style.flexShrink * c._flexBasis)) / totalShrinkScaled + } + + unclamped[i] = t + const clamped = Math.max(0, boundAxis(c.style, isMainRow, t, ownerW, ownerH)) + c._mainSize = clamped + totalViolation += clamped - t + } + + if (totalViolation === 0) { + break + } + + let anyFrozen = false + + for (let i = 0; i < n; i++) { + if (frozen[i]) { + continue + } + + const v = children[i]!._mainSize - unclamped[i]! + + if ((totalViolation > 0 && v > 0) || (totalViolation < 0 && v < 0)) { + frozen[i] = true + anyFrozen = true + } + } + + if (!anyFrozen) { + break + } + } +} + +function isStretchAlign(child: Node): boolean { + const p = child.parent + + if (!p) { + return false + } + + const align = child.style.alignSelf === Align.Auto ? p.style.alignItems : child.style.alignSelf + + return align === Align.Stretch +} + +function resolveChildAlign(parent: Node, child: Node): Align { + return child.style.alignSelf === Align.Auto ? parent.style.alignItems : child.style.alignSelf +} + +function calculateBaseline(node: Node): number { + let baselineChild: Node | null = null + + for (const c of node.children) { + if (c._lineIndex > 0) { + break + } + + if (c.style.positionType === PositionType.Absolute) { + continue + } + + if (c.style.display === Display.None) { + continue + } + + if (resolveChildAlign(node, c) === Align.Baseline || c.isReferenceBaseline_) { + baselineChild = c + + break + } + + if (baselineChild === null) { + baselineChild = c + } + } + + if (baselineChild === null) { + return node.layout.height + } + + return calculateBaseline(baselineChild) + baselineChild.layout.top +} + +function isBaselineLayout(node: Node, flowChildren: Node[]): boolean { + if (!isRow(node.style.flexDirection)) { + return false + } + + if (node.style.alignItems === Align.Baseline) { + return true + } + + for (const c of flowChildren) { + if (c.style.alignSelf === Align.Baseline) { + return true + } + } + + return false +} + +function childMarginForAxis(child: Node, axis: FlexDirection, ownerWidth: number): number { + if (!child._hasMargin) { + return 0 + } + + const lead = resolveEdge(child.style.margin, leadingEdge(axis), ownerWidth) + const trail = resolveEdge(child.style.margin, trailingEdge(axis), ownerWidth) + + return lead + trail +} + +function resolveGap(style: Style, gutter: Gutter, ownerSize: number): number { + let v = style.gap[gutter]! + + if (v.unit === Unit.Undefined) { + v = style.gap[Gutter.All]! + } + + const r = resolveValue(v, ownerSize) + + return isDefined(r) ? Math.max(0, r) : 0 +} + +function boundAxis(style: Style, isWidth: boolean, value: number, ownerWidth: number, ownerHeight: number): number { + const minV = isWidth ? style.minWidth : style.minHeight + const maxV = isWidth ? style.maxWidth : style.maxHeight + const minU = minV.unit + const maxU = maxV.unit + + if (minU === 0 && maxU === 0) { + return value + } + + const owner = isWidth ? ownerWidth : ownerHeight + let v = value + + if (maxU === 1) { + if (v > maxV.value) { + v = maxV.value + } + } else if (maxU === 2) { + const m = (maxV.value * owner) / 100 + + if (m === m && v > m) { + v = m + } + } + + if (minU === 1) { + if (v < minV.value) { + v = minV.value + } + } else if (minU === 2) { + const m = (minV.value * owner) / 100 + + if (m === m && v < m) { + v = m + } + } + + return v +} + +function zeroLayoutRecursive(node: Node): void { + for (const c of node.children) { + c.layout.left = 0 + c.layout.top = 0 + c.layout.width = 0 + c.layout.height = 0 + c.isDirty_ = true + c._hasL = false + c._hasM = false + zeroLayoutRecursive(c) + } +} + +function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void { + for (const c of node.children) { + const disp = c.style.display + + if (disp === Display.None) { + c.layout.left = 0 + c.layout.top = 0 + c.layout.width = 0 + c.layout.height = 0 + zeroLayoutRecursive(c) + } else if (disp === Display.Contents) { + c.layout.left = 0 + c.layout.top = 0 + c.layout.width = 0 + c.layout.height = 0 + collectLayoutChildren(c, flow, abs) + } else if (c.style.positionType === PositionType.Absolute) { + abs.push(c) + } else { + flow.push(c) + } + } +} + +function roundLayout(node: Node, scale: number, absLeft: number, absTop: number): void { + if (scale === 0) { + return + } + + const l = node.layout + const nodeLeft = l.left + const nodeTop = l.top + const nodeWidth = l.width + const nodeHeight = l.height + const absNodeLeft = absLeft + nodeLeft + const absNodeTop = absTop + nodeTop + const isText = node.measureFunc !== null + l.left = roundValue(nodeLeft, scale, false, isText) + l.top = roundValue(nodeTop, scale, false, isText) + const absRight = absNodeLeft + nodeWidth + const absBottom = absNodeTop + nodeHeight + const hasFracW = !isWholeNumber(nodeWidth * scale) + const hasFracH = !isWholeNumber(nodeHeight * scale) + l.width = + roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) - roundValue(absNodeLeft, scale, false, isText) + l.height = + roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) - roundValue(absNodeTop, scale, false, isText) + + for (const c of node.children) { + roundLayout(c, scale, absNodeLeft, absNodeTop) + } +} + +function isWholeNumber(v: number): boolean { + const frac = v - Math.floor(v) + + return frac < 0.0001 || frac > 0.9999 +} + +function roundValue(v: number, scale: number, forceCeil: boolean, forceFloor: boolean): number { + let scaled = v * scale + let frac = scaled - Math.floor(scaled) + + if (frac < 0) { + frac += 1 + } + + if (frac < 0.0001) { + scaled = Math.floor(scaled) + } else if (frac > 0.9999) { + scaled = Math.ceil(scaled) + } else if (forceCeil) { + scaled = Math.ceil(scaled) + } else if (forceFloor) { + scaled = Math.floor(scaled) + } else { + scaled = Math.floor(scaled) + (frac >= 0.4999 ? 1 : 0) + } + + return scaled / scale +} + +function parseDimension(v: number | string | undefined): Value { + if (v === undefined) { + return UNDEFINED_VALUE + } + + if (v === 'auto') { + return AUTO_VALUE + } + + if (typeof v === 'number') { + return Number.isFinite(v) ? pointValue(v) : UNDEFINED_VALUE + } + + if (typeof v === 'string' && v.endsWith('%')) { + return percentValue(parseFloat(v)) + } + + const n = parseFloat(v) + + return isNaN(n) ? UNDEFINED_VALUE : pointValue(n) +} + +function physicalEdge(edge: Edge): number { + switch (edge) { + case Edge.Left: + + case Edge.Start: + return EDGE_LEFT + + case Edge.Top: + return EDGE_TOP + + case Edge.Right: + + case Edge.End: + return EDGE_RIGHT + + case Edge.Bottom: + return EDGE_BOTTOM + + default: + return EDGE_LEFT + } +} + +export type Yoga = { + Config: { + create(): Config + destroy(config: Config): void + } + Node: { + create(config?: Config): Node + createDefault(): Node + createWithConfig(config: Config): Node + destroy(node: Node): void + } +} + +const YOGA_INSTANCE: Yoga = { + Config: { + create: createConfig, + destroy() {} + }, + Node: { + create: (config?: Config) => new Node(config), + createDefault: () => new Node(), + createWithConfig: (config: Config) => new Node(config), + destroy() {} + } +} + +export function loadYoga(): Promise { + return Promise.resolve(YOGA_INSTANCE) +} + +export default YOGA_INSTANCE diff --git a/ui-tui/packages/hermes-ink/src/utils/debug.ts b/ui-tui/packages/hermes-ink/src/utils/debug.ts new file mode 100644 index 0000000000..285a07ac1b --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/debug.ts @@ -0,0 +1,6 @@ +export function logForDebugging( + _message: string, + _options: { + level?: string + } = {} +): void {} diff --git a/ui-tui/packages/hermes-ink/src/utils/earlyInput.ts b/ui-tui/packages/hermes-ink/src/utils/earlyInput.ts new file mode 100644 index 0000000000..bdc8418415 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/earlyInput.ts @@ -0,0 +1,131 @@ +import { lastGrapheme } from './intl.js' +let earlyInputBuffer = '' +let isCapturing = false +let readableHandler: (() => void) | null = null + +export function startCapturingEarlyInput(): void { + if (!process.stdin.isTTY || isCapturing || process.argv.includes('-p') || process.argv.includes('--print')) { + return + } + + isCapturing = true + earlyInputBuffer = '' + + try { + process.stdin.setEncoding('utf8') + process.stdin.setRawMode(true) + process.stdin.ref() + + readableHandler = () => { + let chunk = process.stdin.read() + + while (chunk !== null) { + if (typeof chunk === 'string') { + processChunk(chunk) + } + + chunk = process.stdin.read() + } + } + + process.stdin.on('readable', readableHandler) + } catch { + isCapturing = false + } +} + +function processChunk(str: string): void { + let i = 0 + + while (i < str.length) { + const char = str[i]! + const code = char.charCodeAt(0) + + if (code === 3) { + stopCapturingEarlyInput() + process.exit(130) + + return + } + + if (code === 4) { + stopCapturingEarlyInput() + + return + } + + if (code === 127 || code === 8) { + if (earlyInputBuffer.length > 0) { + const last = lastGrapheme(earlyInputBuffer) + earlyInputBuffer = earlyInputBuffer.slice(0, -(last.length || 1)) + } + + i++ + + continue + } + + if (code === 27) { + i++ + + while (i < str.length && !(str.charCodeAt(i) >= 64 && str.charCodeAt(i) <= 126)) { + i++ + } + + if (i < str.length) { + i++ + } + + continue + } + + if (code < 32 && code !== 9 && code !== 10 && code !== 13) { + i++ + + continue + } + + if (code === 13) { + earlyInputBuffer += '\n' + i++ + + continue + } + + earlyInputBuffer += char + i++ + } +} + +export function stopCapturingEarlyInput(): void { + if (!isCapturing) { + return + } + + isCapturing = false + + if (readableHandler) { + process.stdin.removeListener('readable', readableHandler) + readableHandler = null + } +} + +export function consumeEarlyInput(): string { + stopCapturingEarlyInput() + const input = earlyInputBuffer.trim() + earlyInputBuffer = '' + + return input +} + +export function hasEarlyInput(): boolean { + return earlyInputBuffer.trim().length > 0 +} + +export function seedEarlyInput(text: string): void { + earlyInputBuffer = text +} + +export function isCapturingEarlyInput(): boolean { + return isCapturing +} diff --git a/ui-tui/packages/hermes-ink/src/utils/env.ts b/ui-tui/packages/hermes-ink/src/utils/env.ts new file mode 100644 index 0000000000..7393f1baa7 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/env.ts @@ -0,0 +1,41 @@ +type TerminalName = string | null + +function detectTerminal(): TerminalName { + if (process.env.CURSOR_TRACE_ID) { + return 'cursor' + } + + if (process.env.TERM === 'xterm-ghostty') { + return 'ghostty' + } + + if (process.env.TERM?.includes('kitty')) { + return 'kitty' + } + + if (process.env.TERM_PROGRAM) { + return process.env.TERM_PROGRAM + } + + if (process.env.TMUX) { + return 'tmux' + } + + if (process.env.STY) { + return 'screen' + } + + if (process.env.KITTY_WINDOW_ID) { + return 'kitty' + } + + if (process.env.WT_SESSION) { + return 'windows-terminal' + } + + return process.env.TERM ?? null +} + +export const env = { + terminal: detectTerminal() +} diff --git a/ui-tui/packages/hermes-ink/src/utils/envUtils.ts b/ui-tui/packages/hermes-ink/src/utils/envUtils.ts new file mode 100644 index 0000000000..f3286197b5 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/envUtils.ts @@ -0,0 +1,13 @@ +export function isEnvTruthy(envVar: string | boolean | undefined): boolean { + if (!envVar) { + return false + } + + if (typeof envVar === 'boolean') { + return envVar + } + + const v = envVar.toLowerCase().trim() + + return ['1', 'true', 'yes', 'on'].includes(v) +} diff --git a/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts b/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts new file mode 100644 index 0000000000..106555b13e --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/execFileNoThrow.ts @@ -0,0 +1,64 @@ +import { spawn } from 'child_process' +type ExecFileOptions = { + input?: string + timeout?: number + useCwd?: boolean + env?: NodeJS.ProcessEnv +} + +export function execFileNoThrow( + file: string, + args: string[], + options: ExecFileOptions = {} +): Promise<{ + stdout: string + stderr: string + code: number + error?: string +}> { + return new Promise(resolve => { + const child = spawn(file, args, { + cwd: options.useCwd ? process.cwd() : undefined, + env: options.env, + stdio: 'pipe' + }) + + let stdout = '' + let stderr = '' + let timedOut = false + + const timer = options.timeout + ? setTimeout(() => { + timedOut = true + child.kill('SIGTERM') + }, options.timeout) + : null + + child.stdout?.on('data', chunk => { + stdout += String(chunk) + }) + child.stderr?.on('data', chunk => { + stderr += String(chunk) + }) + child.on('error', error => { + if (timer) { + clearTimeout(timer) + } + + resolve({ stdout, stderr, code: 1, error: String(error) }) + }) + child.on('close', code => { + if (timer) { + clearTimeout(timer) + } + + resolve({ stdout, stderr, code: timedOut ? 124 : (code ?? 0) }) + }) + + if (options.input) { + child.stdin?.write(options.input) + } + + child.stdin?.end() + }) +} diff --git a/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts b/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts new file mode 100644 index 0000000000..7ce9e87587 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts @@ -0,0 +1,3 @@ +export function isMouseClicksDisabled(): boolean { + return false +} diff --git a/ui-tui/packages/hermes-ink/src/utils/intl.ts b/ui-tui/packages/hermes-ink/src/utils/intl.ts new file mode 100644 index 0000000000..6f9dfaf92d --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/intl.ts @@ -0,0 +1,87 @@ +let graphemeSegmenter: Intl.Segmenter | null = null +let wordSegmenter: Intl.Segmenter | null = null + +export function getGraphemeSegmenter(): Intl.Segmenter { + if (!graphemeSegmenter) { + graphemeSegmenter = new Intl.Segmenter(undefined, { + granularity: 'grapheme' + }) + } + + return graphemeSegmenter +} + +export function firstGrapheme(text: string): string { + if (!text) { + return '' + } + + const segments = getGraphemeSegmenter().segment(text) + const first = segments[Symbol.iterator]().next().value + + return first?.segment ?? '' +} + +export function lastGrapheme(text: string): string { + if (!text) { + return '' + } + + let last = '' + + for (const { segment } of getGraphemeSegmenter().segment(text)) { + last = segment + } + + return last +} + +export function getWordSegmenter(): Intl.Segmenter { + if (!wordSegmenter) { + wordSegmenter = new Intl.Segmenter(undefined, { granularity: 'word' }) + } + + return wordSegmenter +} + +const rtfCache = new Map() + +export function getRelativeTimeFormat( + style: 'long' | 'short' | 'narrow', + numeric: 'always' | 'auto' +): Intl.RelativeTimeFormat { + const key = `${style}:${numeric}` + let rtf = rtfCache.get(key) + + if (!rtf) { + rtf = new Intl.RelativeTimeFormat('en', { style, numeric }) + rtfCache.set(key, rtf) + } + + return rtf +} + +let cachedTimeZone: string | null = null + +export function getTimeZone(): string { + if (!cachedTimeZone) { + cachedTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone + } + + return cachedTimeZone +} + +let cachedSystemLocaleLanguage: string | undefined | null = null + +export function getSystemLocaleLanguage(): string | undefined { + if (cachedSystemLocaleLanguage === null) { + try { + const locale = Intl.DateTimeFormat().resolvedOptions().locale + cachedSystemLocaleLanguage = new Intl.Locale(locale).language + } catch { + cachedSystemLocaleLanguage = undefined + } + } + + return cachedSystemLocaleLanguage +} diff --git a/ui-tui/packages/hermes-ink/src/utils/log.ts b/ui-tui/packages/hermes-ink/src/utils/log.ts new file mode 100644 index 0000000000..369763eee0 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/log.ts @@ -0,0 +1,7 @@ +export function logError(error: unknown): void { + if (!process.env.HERMES_INK_DEBUG_ERRORS) { + return + } + + console.error(error) +} diff --git a/ui-tui/packages/hermes-ink/src/utils/semver.ts b/ui-tui/packages/hermes-ink/src/utils/semver.ts new file mode 100644 index 0000000000..ab57ecf720 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/semver.ts @@ -0,0 +1,57 @@ +let _npmSemver: typeof import('semver') | undefined + +function getNpmSemver(): typeof import('semver') { + if (!_npmSemver) { + _npmSemver = require('semver') as typeof import('semver') + } + + return _npmSemver +} + +export function gt(a: string, b: string): boolean { + if (typeof Bun !== 'undefined') { + return Bun.semver.order(a, b) === 1 + } + + return getNpmSemver().gt(a, b, { loose: true }) +} + +export function gte(a: string, b: string): boolean { + if (typeof Bun !== 'undefined') { + return Bun.semver.order(a, b) >= 0 + } + + return getNpmSemver().gte(a, b, { loose: true }) +} + +export function lt(a: string, b: string): boolean { + if (typeof Bun !== 'undefined') { + return Bun.semver.order(a, b) === -1 + } + + return getNpmSemver().lt(a, b, { loose: true }) +} + +export function lte(a: string, b: string): boolean { + if (typeof Bun !== 'undefined') { + return Bun.semver.order(a, b) <= 0 + } + + return getNpmSemver().lte(a, b, { loose: true }) +} + +export function satisfies(version: string, range: string): boolean { + if (typeof Bun !== 'undefined') { + return Bun.semver.satisfies(version, range) + } + + return getNpmSemver().satisfies(version, range, { loose: true }) +} + +export function order(a: string, b: string): -1 | 0 | 1 { + if (typeof Bun !== 'undefined') { + return Bun.semver.order(a, b) + } + + return getNpmSemver().compare(a, b, { loose: true }) +} diff --git a/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts new file mode 100644 index 0000000000..7be1950b12 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/utils/sliceAnsi.ts @@ -0,0 +1,58 @@ +import { type AnsiCode, ansiCodesToString, reduceAnsiCodes, tokenize, undoAnsiCodes } from '@alcalzone/ansi-tokenize' + +import { stringWidth } from '../ink/stringWidth.js' + +function isEndCode(code: AnsiCode): boolean { + return code.code === code.endCode +} + +function filterStartCodes(codes: AnsiCode[]): AnsiCode[] { + return codes.filter(c => !isEndCode(c)) +} + +export default function sliceAnsi(str: string, start: number, end?: number): string { + const tokens = tokenize(str) + let activeCodes: AnsiCode[] = [] + let position = 0 + let result = '' + let include = false + + for (const token of tokens) { + const width = token.type === 'ansi' ? 0 : token.fullWidth ? 2 : stringWidth(token.value) + + if (end !== undefined && position >= end) { + if (token.type === 'ansi' || width > 0 || !include) { + break + } + } + + if (token.type === 'ansi') { + activeCodes.push(token) + + if (include) { + result += token.code + } + } else { + if (!include && position >= start) { + if (start > 0 && width === 0) { + continue + } + + include = true + activeCodes = filterStartCodes(reduceAnsiCodes(activeCodes)) + result = ansiCodesToString(activeCodes) + } + + if (include) { + result += token.value + } + + position += width + } + } + + const activeStartCodes = filterStartCodes(reduceAnsiCodes(activeCodes)) + result += ansiCodesToString(undoAnsiCodes(activeStartCodes)) + + return result +} diff --git a/ui-tui/packages/hermes-ink/text-input.d.ts b/ui-tui/packages/hermes-ink/text-input.d.ts new file mode 100644 index 0000000000..f9f5df1c8d --- /dev/null +++ b/ui-tui/packages/hermes-ink/text-input.d.ts @@ -0,0 +1,2 @@ +export { default, UncontrolledTextInput } from 'ink-text-input' +export type { Props } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/text-input.js b/ui-tui/packages/hermes-ink/text-input.js new file mode 100644 index 0000000000..8cb79c0ccb --- /dev/null +++ b/ui-tui/packages/hermes-ink/text-input.js @@ -0,0 +1 @@ +export { default, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 91f45eabfc..56517645d8 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Box, Text, useApp, useInput, useStdout } from 'ink' +import { Box, Text, useApp, useInput, useStdout } from '@hermes/ink' import { useCallback, useEffect, useRef, useState } from 'react' import { Banner, SessionPanel } from './components/branding.js' @@ -1330,19 +1330,30 @@ export function App({ gw }: { gw: GatewayClient }) { const lines: string[] = [] for (const { name: catName, pairs } of cats) { - if (lines.length) lines.push('') + if (lines.length) { + lines.push('') + } + lines.push(` ${catName}:`) - for (const [c, d] of pairs) lines.push(` ${c.padEnd(18)} ${d}`) + + for (const [c, d] of pairs) { + lines.push(` ${c.padEnd(18)} ${d}`) + } } - if (!lines.length) lines.push(' (no commands loaded)') + if (!lines.length) { + lines.push(' (no commands loaded)') + } if (skills > 0) { lines.push('', ` ${skills} skill commands available — /skills to browse`) } lines.push('', ' Hotkeys:') - for (const [k, d] of HOTKEYS) lines.push(` ${k.padEnd(14)} ${d}`) + + for (const [k, d] of HOTKEYS) { + lines.push(` ${k.padEnd(14)} ${d}`) + } sys(lines.join('\n')) @@ -1371,8 +1382,11 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'resume': - if (arg) resumeById(arg) - else setPicker(true) + if (arg) { + resumeById(arg) + } else { + setPicker(true) + } return true @@ -1779,7 +1793,6 @@ export function App({ gw }: { gw: GatewayClient }) { }) return true - case 'skills': { const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean) @@ -1787,7 +1800,9 @@ export function App({ gw }: { gw: GatewayClient }) { rpc('skills.manage', { action: 'list' }).then((r: any) => { const sk = r.skills as Record | undefined - if (!sk || !Object.keys(sk).length) return sys('no skills installed') + if (!sk || !Object.keys(sk).length) { + return sys('no skills installed') + } const lines: string[] = [] @@ -1804,18 +1819,26 @@ export function App({ gw }: { gw: GatewayClient }) { if (sub === 'browse') { const page = parseInt(sArgs[0] ?? '1', 10) || 1 rpc('skills.manage', { action: 'browse', page }).then((r: any) => { - if (!r.items?.length) return sys('no skills found in the hub') + if (!r.items?.length) { + return sys('no skills found in the hub') + } const lines = [ ` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, '', - ...r.items.map((s: any) => - ` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}` - ), + ...r.items.map( + (s: any) => + ` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}` + ) ] - if (r.page < r.total_pages) lines.push('', ` /skills browse ${r.page + 1} → next page`) - if (r.page > 1) lines.push(` /skills browse ${r.page - 1} → prev page`) + if (r.page < r.total_pages) { + lines.push('', ` /skills browse ${r.page + 1} → next page`) + } + + if (r.page > 1) { + lines.push(` /skills browse ${r.page - 1} → prev page`) + } sys(lines.join('\n')) }) @@ -1853,7 +1876,22 @@ export function App({ gw }: { gw: GatewayClient }) { return true } }, - [catalog, compact, gw, lastUserMsg, messages, newSession, page, pastes, pushActivity, rpc, send, sid, statusBar, sys] + [ + catalog, + compact, + gw, + lastUserMsg, + messages, + newSession, + page, + pastes, + pushActivity, + rpc, + send, + sid, + statusBar, + sys + ] ) slashRef.current = slash @@ -2037,12 +2075,7 @@ export function App({ gw }: { gw: GatewayClient }) { {picker && ( - setPicker(false)} - onSelect={resumeById} - t={theme} - /> + setPicker(false)} onSelect={resumeById} t={theme} /> )} @@ -2102,6 +2135,7 @@ export function App({ gw }: { gw: GatewayClient }) { = 90 && leftW + 40 < cols - const w = wide ? cols - leftW - 12 : cols - 10 + // Keep an explicit gutter so right border never gets overwritten by long lines. + const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12) + const lineBudget = Math.max(12, w - 2) const cwd = info.cwd || process.cwd() const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}` @@ -54,7 +56,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string for (const item of items.sort()) { const next = line ? `${line}, ${item}` : item - if (pfx.length + next.length > w) { + if (pfx.length + next.length > lineBudget) { return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` } @@ -122,10 +124,10 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {typeof info.update_behind === 'number' && info.update_behind > 0 && ( - ⚠ {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind + ! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind {' '} - — run{' '} + - run{' '} {info.update_command || 'hermes update'} diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 5882ab8c7e..64403c2977 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from 'ink' +import { Box, Text } from '@hermes/ink' import type { ReactNode } from 'react' import type { Theme } from '../theme.js' diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index f2e8d95ce7..60e4ed16ae 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -1,5 +1,4 @@ -import { Box, Text } from 'ink' -import TextInput from 'ink-text-input' +import { Box, Text, TextInput } from '@hermes/ink' import { useState } from 'react' import type { Theme } from '../theme.js' diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 8b8b30894b..5bf70b0a53 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,8 +1,8 @@ -import { Box, Text } from 'ink' +import { Box, Text } from '@hermes/ink' import { memo } from 'react' import { LONG_MSG, ROLE } from '../constants.js' -import { hasAnsi, userDisplay } from '../lib/text.js' +import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg } from '../types.js' @@ -21,9 +21,13 @@ export const MessageLine = memo(function MessageLine({ t: Theme }) { if (msg.role === 'tool') { + const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) + return ( - {msg.text} + + {preview || '(empty tool result)'} + ) } @@ -39,7 +43,7 @@ export const MessageLine = memo(function MessageLine({ return hasAnsi(msg.text) ? {msg.text} : } - if (msg.role === 'user' && msg.text.length > LONG_MSG) { + if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) { const [head, ...rest] = userDisplay(msg.text).split('[long message]') return ( diff --git a/ui-tui/src/components/pasteShelf.tsx b/ui-tui/src/components/pasteShelf.tsx index 717a1a798e..49c050ccef 100644 --- a/ui-tui/src/components/pasteShelf.tsx +++ b/ui-tui/src/components/pasteShelf.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from 'ink' +import { Box, Text } from '@hermes/ink' import { compactPreview } from '../lib/text.js' import type { Theme } from '../theme.js' diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index cc9f743883..05c97665c3 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -1,5 +1,4 @@ -import { Box, Text, useInput } from 'ink' -import TextInput from 'ink-text-input' +import { Box, Text, TextInput, useInput } from '@hermes/ink' import { useState } from 'react' import type { Theme } from '../theme.js' @@ -43,7 +42,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => return ( - ⚠️ DANGEROUS COMMAND: {req.description} + ! DANGEROUS COMMAND: {req.description} {req.command} diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index 7bfe7227ae..2bf578eb41 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from 'ink' +import { Box, Text } from '@hermes/ink' import { compactPreview } from '../lib/text.js' import type { Theme } from '../theme.js' diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index 41c033500c..27a837794a 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -1,4 +1,4 @@ -import { Box, Text, useInput } from 'ink' +import { Box, Text, useInput } from '@hermes/ink' import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' @@ -115,9 +115,7 @@ export function SessionPicker({ ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) - - {s.title || s.preview || '(untitled)'} - + {s.title || s.preview || '(untitled)'} ) })} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index f5deb9c49c..5b3ad22035 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1,5 +1,35 @@ -import { Text, useInput, useStdin } from 'ink' -import { useEffect, useRef, useState } from 'react' +import * as Ink from '@hermes/ink' +import { useEffect, useMemo, useRef, useState } from 'react' + +type InkExt = typeof Ink & { + stringWidth: (s: string) => number + useDeclaredCursor: (a: { line: number; column: number; active: boolean }) => (el: any) => void + useTerminalFocus: () => boolean +} + +const ink = Ink as unknown as InkExt +const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink + +// ── ANSI escapes ───────────────────────────────────────────────────── + +const ESC = '\x1b' +const INV = `${ESC}[7m` +const INV_OFF = `${ESC}[27m` +const DIM = `${ESC}[2m` +const DIM_OFF = `${ESC}[22m` +const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) +const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ +const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') + +const invert = (s: string) => INV + s + INV_OFF +const dim = (s: string) => DIM + s + DIM_OFF + +// ── Grapheme segmenter (lazy singleton) ────────────────────────────── + +let _seg: Intl.Segmenter | null = null +const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) + +// ── Word movement ──────────────────────────────────────────────────── function wordLeft(s: string, p: number) { let i = p - 1 @@ -29,36 +59,94 @@ function wordRight(s: string, p: number) { return i } -const FWD_DELETE_RE = /\x1b\[3[~$^]|\x1b\[3;/ +// ── Cursor layout (line/column from offset + terminal width) ───────── -function useForwardDeleteRef(isActive: boolean) { +function cursorLayout(value: string, cursor: number, cols: number) { + const pos = Math.max(0, Math.min(cursor, value.length)) + const w = Math.max(1, cols - 1) + + let col = 0, + line = 0 + + for (const { segment, index } of seg().segment(value)) { + if (index >= pos) { + break + } + + if (segment === '\n') { + line++ + col = 0 + + continue + } + + const sw = stringWidth(segment) + + if (!sw) { + continue + } + + if (col + sw > w) { + line++ + col = 0 + } + + col += sw + } + + return { column: col, line } +} + +// ── Render value with inverse-video cursor ─────────────────────────── + +function renderWithCursor(value: string, cursor: number) { + const pos = Math.max(0, Math.min(cursor, value.length)) + + let out = '', + done = false + + for (const { segment, index } of seg().segment(value)) { + if (!done && index >= pos) { + out += invert(index === pos && segment !== '\n' ? segment : ' ') + done = true + + if (index === pos && segment !== '\n') { + continue + } + } + + out += segment + } + + return done ? out : out + invert(' ') +} + +// ── Forward-delete detection hook ──────────────────────────────────── + +function useFwdDelete(active: boolean) { const ref = useRef(false) - const { internal_eventEmitter: ee } = useStdin() + const { inputEmitter: ee } = useStdin() useEffect(() => { - if (!isActive) return - - const onInput = (data: string) => { - ref.current = FWD_DELETE_RE.test(data) + if (!active) { + return } - ee.prependListener('input', onInput) + const h = (d: string) => { + ref.current = FWD_DEL_RE.test(d) + } + + ee.prependListener('input', h) return () => { - ee.removeListener('input', onInput) + ee.removeListener('input', h) } - }, [isActive, ee]) + }, [active, ee]) return ref } -const ESC = '\x1b' -const INV = ESC + '[7m' -const INV_OFF = ESC + '[27m' -const DIM = ESC + '[2m' -const DIM_OFF = ESC + '[22m' -const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ -const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') +// ── Types ──────────────────────────────────────────────────────────── export interface PasteEvent { bracketed?: boolean @@ -69,6 +157,7 @@ export interface PasteEvent { } interface Props { + columns?: number value: string onChange: (v: string) => void onSubmit?: (v: string) => void @@ -77,35 +166,64 @@ interface Props { focus?: boolean } -export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { +// ── Component ──────────────────────────────────────────────────────── + +export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { const [cur, setCur] = useState(value.length) - const isFwdDelete = useForwardDeleteRef(focus) + const fwdDel = useFwdDelete(focus) + const termFocus = useTerminalFocus() const curRef = useRef(cur) const vRef = useRef(value) - const selfChange = useRef(false) + const self = useRef(false) const pasteBuf = useRef('') const pasteTimer = useRef | null>(null) const pastePos = useRef(0) - const undoStack = useRef>([]) - const redoStack = useRef>([]) + const undo = useRef<{ cursor: number; value: string }[]>([]) + const redo = useRef<{ cursor: number; value: string }[]>([]) - const onChangeRef = useRef(onChange) - const onSubmitRef = useRef(onSubmit) - const onPasteRef = useRef(onPaste) - onChangeRef.current = onChange - onSubmitRef.current = onSubmit - onPasteRef.current = onPaste + const cbChange = useRef(onChange) + const cbSubmit = useRef(onSubmit) + const cbPaste = useRef(onPaste) + cbChange.current = onChange + cbSubmit.current = onSubmit + cbPaste.current = onPaste + + const display = self.current ? vRef.current : value + + // ── Cursor declaration ─────────────────────────────────────────── + + const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display]) + + const boxRef = useDeclaredCursor({ + line: layout.line, + column: layout.column, + active: focus && termFocus + }) + + const rendered = useMemo(() => { + if (!focus) { + return display || dim(placeholder) + } + + if (!display && placeholder) { + return invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1)) + } + + return renderWithCursor(display, cur) + }, [cur, display, focus, placeholder]) + + // ── Sync external value changes ────────────────────────────────── useEffect(() => { - if (selfChange.current) { - selfChange.current = false + if (self.current) { + self.current = false } else { setCur(value.length) curRef.current = value.length vRef.current = value - undoStack.current = [] - redoStack.current = [] + undo.current = [] + redo.current = [] } }, [value]) @@ -118,20 +236,20 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' [] ) - // ── Buffer ops (synchronous, ref-based) ───────────────────────── + // ── Buffer ops (synchronous, ref-based) ────────────────────────── const commit = (next: string, nextCur: number, track = true) => { const prev = vRef.current const c = Math.max(0, Math.min(nextCur, next.length)) if (track && next !== prev) { - undoStack.current.push({ cursor: curRef.current, value: prev }) + undo.current.push({ cursor: curRef.current, value: prev }) - if (undoStack.current.length > 200) { - undoStack.current.shift() + if (undo.current.length > 200) { + undo.current.shift() } - redoStack.current = [] + redo.current = [] } setCur(c) @@ -139,12 +257,12 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' vRef.current = next if (next !== prev) { - selfChange.current = true - onChangeRef.current(next) + self.current = true + cbChange.current(next) } } - const swap = (from: typeof undoStack, to: typeof redoStack) => { + const swap = (from: typeof undo, to: typeof redo) => { const entry = from.current.pop() if (!entry) { @@ -156,13 +274,13 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } const emitPaste = (e: PasteEvent) => { - const handled = onPasteRef.current?.(e) + const h = cbPaste.current?.(e) - if (handled) { - commit(handled.value, handled.cursor) + if (h) { + commit(h.value, h.cursor) } - return !!handled + return !!h } const flushPaste = () => { @@ -180,20 +298,18 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } } - const insert = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c) + const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c) - // ── Input handler ─────────────────────────────────────────────── + // ── Input handler ──────────────────────────────────────────────── useInput( (inp, k) => { - // Paste hotkeys — single owner, no competing listeners in App + // Paste hotkey if ((k.ctrl || k.meta) && inp.toLowerCase() === 'v') { - emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) - - return + return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } - // Keys handled by App.useInput + // Delegated to App if ( k.upArrow || k.downArrow || @@ -209,8 +325,8 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' if (k.return) { k.shift || k.meta - ? commit(insert(vRef.current, curRef.current, '\n'), curRef.current + 1) - : onSubmitRef.current?.(vRef.current) + ? commit(ins(vRef.current, curRef.current, '\n'), curRef.current + 1) + : cbSubmit.current?.(vRef.current) return } @@ -219,14 +335,16 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' let v = vRef.current const mod = k.ctrl || k.meta + // Undo / redo if (k.ctrl && inp === 'z') { - return swap(undoStack, redoStack) + return swap(undo, redo) } if ((k.ctrl && inp === 'y') || (k.meta && k.shift && inp === 'z')) { - return swap(redoStack, undoStack) + return swap(redo, undo) } + // Navigation if (k.home || (k.ctrl && inp === 'a')) { c = 0 } else if (k.end || (k.ctrl && inp === 'e')) { @@ -235,7 +353,14 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' c = mod ? wordLeft(v, c) : Math.max(0, c - 1) } else if (k.rightArrow) { c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) - } else if ((k.backspace || k.delete) && !isFwdDelete.current && c > 0) { + } else if (k.meta && inp === 'b') { + c = wordLeft(v, c) + } else if (k.meta && inp === 'f') { + c = wordRight(v, c) + } + + // Deletion + else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -244,7 +369,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' v = v.slice(0, c - 1) + v.slice(c) c-- } - } else if (k.delete && isFwdDelete.current && c < v.length) { + } else if (k.delete && fwdDel.current && c < v.length) { if (mod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) @@ -260,11 +385,10 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' c = 0 } else if (k.ctrl && inp === 'k') { v = v.slice(0, c) - } else if (k.meta && inp === 'b') { - c = wordLeft(v, c) - } else if (k.meta && inp === 'f') { - c = wordRight(v, c) - } else if (inp.length > 0) { + } + + // Text insertion / paste buffering + else if (inp.length > 0) { const bracketed = inp.includes('[200~') const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') @@ -277,7 +401,7 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' } if (raw === '\n') { - return commit(insert(v, c, '\n'), c + 1) + return commit(ins(v, c, '\n'), c + 1) } if (raw.length > 1 || raw.includes('\n')) { @@ -311,20 +435,11 @@ export function TextInput({ value, onChange, onPaste, onSubmit, placeholder = '' { isActive: focus } ) - // ── Render ────────────────────────────────────────────────────── - - if (!focus) { - return {value || (placeholder ? DIM + placeholder + DIM_OFF : '')} - } - - if (!value && placeholder) { - return {INV + (placeholder[0] ?? ' ') + INV_OFF + DIM + placeholder.slice(1) + DIM_OFF} - } + // ── Render ─────────────────────────────────────────────────────── return ( - - {[...value].map((ch, i) => (i === cur ? INV + ch + INV_OFF : ch)).join('') + - (cur === value.length ? INV + ' ' + INV_OFF : '')} - + + {rendered} + ) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index f4f5130eec..b2b8c3d597 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,4 +1,4 @@ -import { Text } from 'ink' +import { Text } from '@hermes/ink' import { memo, useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' @@ -21,7 +21,7 @@ const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', const tone = (item: ActivityItem, t: Theme) => item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim -const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '⚠' : '·') +const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·') const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '} diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 3f719f4e1b..ca52ec91c5 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { render } from 'ink' +import { render } from '@hermes/ink' import React from 'react' import { App } from './app.js' @@ -13,7 +13,5 @@ if (!process.stdin.isTTY) { const gw = new GatewayClient() gw.start() render(, { - exitOnCtrlC: false, - maxFps: 60, - kittyKeyboard: { mode: 'enabled', flags: ['disambiguateEscapeCodes'] }, + exitOnCtrlC: false }) diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index a700127115..50054e90d0 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { startTransition, useEffect, useRef, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' @@ -53,9 +53,11 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient return } - setCompletions(r?.items ?? []) - setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + startTransition(() => { + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + }) }) .catch(() => {}) }, 60) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 7f835c0cd4..c0299ccc12 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -137,3 +137,6 @@ export const userDisplay = (text: string): string => { return `${prefix || '(message)'} [long message]` } + +export const isPasteBackedText = (text: string): boolean => + /\[\[paste:\d+\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text) diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts new file mode 100644 index 0000000000..db77c9f2a0 --- /dev/null +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -0,0 +1,65 @@ +import type * as React from 'react' + +declare module '@hermes/ink' { + export type Key = { + readonly ctrl: boolean + readonly meta: boolean + readonly shift: boolean + readonly alt: boolean + readonly upArrow: boolean + readonly downArrow: boolean + readonly leftArrow: boolean + readonly rightArrow: boolean + readonly return: boolean + readonly backspace: boolean + readonly delete: boolean + readonly escape: boolean + readonly tab: boolean + readonly pageUp: boolean + readonly pageDown: boolean + readonly home: boolean + readonly end: boolean + readonly [key: string]: boolean + } + + export type InputHandler = (input: string, key: Key) => void + + export type RenderOptions = { + readonly stdin?: NodeJS.ReadStream + readonly stdout?: NodeJS.WriteStream + readonly stderr?: NodeJS.WriteStream + readonly exitOnCtrlC?: boolean + } + + export type Instance = { + readonly rerender: (node: React.ReactNode) => void + readonly unmount: () => void + readonly waitUntilExit: () => Promise + readonly cleanup: () => void + } + + export const Box: React.ComponentType + export const Text: React.ComponentType + export const TextInput: React.ComponentType + export const stringWidth: (s: string) => number + + export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance + + export function useApp(): { readonly exit: (error?: Error) => void } + export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void + export function useStdout(): { readonly stdout?: NodeJS.WriteStream } + export function useTerminalFocus(): boolean + export function useDeclaredCursor(args: { + readonly line: number + readonly column: number + readonly active: boolean + }): (el: unknown) => void + export function useStdin(): { + readonly stdin: NodeJS.ReadStream + readonly setRawMode: (value: boolean) => void + readonly isRawModeSupported: boolean + readonly exitOnCtrlC: boolean + readonly inputEmitter: NodeJS.EventEmitter + readonly querier: unknown + } +} diff --git a/ui-tui/tsconfig.build.json b/ui-tui/tsconfig.build.json new file mode 100644 index 0000000000..a0a8b410d8 --- /dev/null +++ b/ui-tui/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@hermes/ink": ["src/types/hermes-ink.d.ts"] + } + } +} diff --git a/ui-tui/tsconfig.json b/ui-tui/tsconfig.json index b7817e13a6..67a50d6a7b 100644 --- a/ui-tui/tsconfig.json +++ b/ui-tui/tsconfig.json @@ -14,6 +14,6 @@ "sourceMap": false, "resolveJsonModule": true }, - "include": ["src/**/*.ts", "src/**/*.tsx"], + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.tsx"], "exclude": ["src/__tests__", "node_modules", "dist"] } From 3fd5cf6e3c3db4e275114c2e08a2d2897dda2ea4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 13:14:32 -0500 Subject: [PATCH 069/157] feat: fix img pasting in new ink plus newline after tools --- hermes_cli/clipboard.py | 180 +++++++++++------- tests/tools/test_clipboard.py | 45 +++++ ui-tui/packages/hermes-ink/src/ink/dom.ts | 1 - .../packages/hermes-ink/src/ink/selection.ts | 2 +- ui-tui/src/app.tsx | 23 ++- ui-tui/src/components/textInput.tsx | 9 +- ui-tui/src/components/thinking.tsx | 12 +- ui-tui/src/lib/text.ts | 1 + 8 files changed, 198 insertions(+), 75 deletions(-) diff --git a/hermes_cli/clipboard.py b/hermes_cli/clipboard.py index dfaaf99cd0..facc8f3c50 100644 --- a/hermes_cli/clipboard.py +++ b/hermes_cli/clipboard.py @@ -7,8 +7,8 @@ CLI tools that ship with the platform (or are commonly installed). Platform support: macOS — osascript (always available), pngpaste (if installed) - Windows — PowerShell via .NET System.Windows.Forms.Clipboard - WSL2 — powershell.exe via .NET System.Windows.Forms.Clipboard + Windows — PowerShell via WinForms, Get-Clipboard, file-drop fallback + WSL2 — powershell.exe via WinForms, Get-Clipboard, file-drop fallback Linux — wl-paste (Wayland), xclip (X11) """ @@ -136,6 +136,114 @@ _PS_EXTRACT_IMAGE = ( "[System.Convert]::ToBase64String($ms.ToArray())" ) +_PS_CHECK_IMAGE_GET_CLIPBOARD = ( + "try { " + "$img = Get-Clipboard -Format Image -ErrorAction Stop;" + "if ($null -ne $img) { 'True' } else { 'False' }" + "} catch { 'False' }" +) + +_PS_EXTRACT_IMAGE_GET_CLIPBOARD = ( + "try { " + "Add-Type -AssemblyName System.Drawing;" + "Add-Type -AssemblyName PresentationCore;" + "Add-Type -AssemblyName WindowsBase;" + "$img = Get-Clipboard -Format Image -ErrorAction Stop;" + "if ($null -eq $img) { exit 1 }" + "$ms = New-Object System.IO.MemoryStream;" + "if ($img -is [System.Drawing.Image]) {" + "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)" + "} elseif ($img -is [System.Windows.Media.Imaging.BitmapSource]) {" + "$enc = New-Object System.Windows.Media.Imaging.PngBitmapEncoder;" + "$enc.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($img));" + "$enc.Save($ms)" + "} else { exit 2 }" + "[System.Convert]::ToBase64String($ms.ToArray())" + "} catch { exit 1 }" +) + +_FILEDROP_IMAGE_EXTS = "'.png','.jpg','.jpeg','.gif','.webp','.bmp','.tiff','.tif'" + +_PS_CHECK_FILEDROP_IMAGE = ( + "try { " + "$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;" + f"$exts = @({_FILEDROP_IMAGE_EXTS});" + "$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;" + "if ($null -ne $hit) { 'True' } else { 'False' }" + "} catch { 'False' }" +) + +_PS_EXTRACT_FILEDROP_IMAGE = ( + "try { " + "$files = Get-Clipboard -Format FileDropList -ErrorAction Stop;" + f"$exts = @({_FILEDROP_IMAGE_EXTS});" + "$hit = $files | Where-Object { $exts -contains ([System.IO.Path]::GetExtension($_).ToLowerInvariant()) } | Select-Object -First 1;" + "if ($null -eq $hit) { exit 1 }" + "[System.Convert]::ToBase64String([System.IO.File]::ReadAllBytes($hit))" + "} catch { exit 1 }" +) + +_POWERSHELL_HAS_IMAGE_SCRIPTS = ( + _PS_CHECK_IMAGE, + _PS_CHECK_IMAGE_GET_CLIPBOARD, + _PS_CHECK_FILEDROP_IMAGE, +) + +_POWERSHELL_EXTRACT_IMAGE_SCRIPTS = ( + _PS_EXTRACT_IMAGE, + _PS_EXTRACT_IMAGE_GET_CLIPBOARD, + _PS_EXTRACT_FILEDROP_IMAGE, +) + + +def _run_powershell(exe: str, script: str, timeout: int) -> subprocess.CompletedProcess: + return subprocess.run( + [exe, "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, text=True, timeout=timeout, + ) + + +def _write_base64_image(dest: Path, b64_data: str) -> bool: + image_bytes = base64.b64decode(b64_data, validate=True) + dest.write_bytes(image_bytes) + return dest.exists() and dest.stat().st_size > 0 + + +def _powershell_has_image(exe: str, *, timeout: int, label: str) -> bool: + for script in _POWERSHELL_HAS_IMAGE_SCRIPTS: + try: + r = _run_powershell(exe, script, timeout=timeout) + if r.returncode == 0 and "True" in r.stdout: + return True + except FileNotFoundError: + logger.debug("%s not found — clipboard unavailable", exe) + return False + except Exception as e: + logger.debug("%s clipboard image check failed: %s", label, e) + return False + + +def _powershell_save_image(exe: str, dest: Path, *, timeout: int, label: str) -> bool: + for script in _POWERSHELL_EXTRACT_IMAGE_SCRIPTS: + try: + r = _run_powershell(exe, script, timeout=timeout) + if r.returncode != 0: + continue + + b64_data = r.stdout.strip() + if not b64_data: + continue + + if _write_base64_image(dest, b64_data): + return True + except FileNotFoundError: + logger.debug("%s not found — clipboard unavailable", exe) + return False + except Exception as e: + logger.debug("%s clipboard image extraction failed: %s", label, e) + dest.unlink(missing_ok=True) + return False + # ── Native Windows ──────────────────────────────────────────────────────── @@ -176,15 +284,7 @@ def _windows_has_image() -> bool: ps = _get_ps_exe() if ps is None: return False - try: - r = subprocess.run( - [ps, "-NoProfile", "-NonInteractive", "-Command", _PS_CHECK_IMAGE], - capture_output=True, text=True, timeout=5, - ) - return r.returncode == 0 and "True" in r.stdout - except Exception as e: - logger.debug("Windows clipboard image check failed: %s", e) - return False + return _powershell_has_image(ps, timeout=5, label="Windows") def _windows_save(dest: Path) -> bool: @@ -193,26 +293,7 @@ def _windows_save(dest: Path) -> bool: if ps is None: logger.debug("No PowerShell found — Windows clipboard image paste unavailable") return False - try: - r = subprocess.run( - [ps, "-NoProfile", "-NonInteractive", "-Command", _PS_EXTRACT_IMAGE], - capture_output=True, text=True, timeout=15, - ) - if r.returncode != 0: - return False - - b64_data = r.stdout.strip() - if not b64_data: - return False - - png_bytes = base64.b64decode(b64_data) - dest.write_bytes(png_bytes) - return dest.exists() and dest.stat().st_size > 0 - - except Exception as e: - logger.debug("Windows clipboard image extraction failed: %s", e) - dest.unlink(missing_ok=True) - return False + return _powershell_save_image(ps, dest, timeout=15, label="Windows") # ── Linux ──────────────────────────────────────────────────────────────── @@ -236,45 +317,12 @@ def _linux_save(dest: Path) -> bool: def _wsl_has_image() -> bool: """Check if Windows clipboard has an image (via powershell.exe).""" - try: - r = subprocess.run( - ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", - _PS_CHECK_IMAGE], - capture_output=True, text=True, timeout=8, - ) - return r.returncode == 0 and "True" in r.stdout - except FileNotFoundError: - logger.debug("powershell.exe not found — WSL clipboard unavailable") - except Exception as e: - logger.debug("WSL clipboard check failed: %s", e) - return False + return _powershell_has_image("powershell.exe", timeout=8, label="WSL") def _wsl_save(dest: Path) -> bool: """Extract clipboard image via powershell.exe → base64 → decode to PNG.""" - try: - r = subprocess.run( - ["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", - _PS_EXTRACT_IMAGE], - capture_output=True, text=True, timeout=15, - ) - if r.returncode != 0: - return False - - b64_data = r.stdout.strip() - if not b64_data: - return False - - png_bytes = base64.b64decode(b64_data) - dest.write_bytes(png_bytes) - return dest.exists() and dest.stat().st_size > 0 - - except FileNotFoundError: - logger.debug("powershell.exe not found — WSL clipboard unavailable") - except Exception as e: - logger.debug("WSL clipboard extraction failed: %s", e) - dest.unlink(missing_ok=True) - return False + return _powershell_save_image("powershell.exe", dest, timeout=15, label="WSL") # ── Wayland (wl-paste) ────────────────────────────────────────────────── diff --git a/tests/tools/test_clipboard.py b/tests/tools/test_clipboard.py index a491edfaa0..17f929eb9c 100644 --- a/tests/tools/test_clipboard.py +++ b/tests/tools/test_clipboard.py @@ -250,6 +250,15 @@ class TestWslHasImage: mock_run.return_value = MagicMock(stdout="False\n", returncode=0) assert _wsl_has_image() is False + def test_falls_back_to_get_clipboard_image(self): + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="False\n", returncode=0), + MagicMock(stdout="True\n", returncode=0), + ] + assert _wsl_has_image() is True + assert mock_run.call_count == 2 + def test_powershell_not_found(self): with patch("hermes_cli.clipboard.subprocess.run", side_effect=FileNotFoundError): assert _wsl_has_image() is False @@ -269,6 +278,18 @@ class TestWslSave: assert _wsl_save(dest) is True assert dest.read_bytes() == FAKE_PNG + def test_falls_back_to_get_clipboard_extraction(self, tmp_path): + dest = tmp_path / "out.png" + b64_png = base64.b64encode(FAKE_PNG).decode() + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="", returncode=1), + MagicMock(stdout=b64_png + "\n", returncode=0), + ] + assert _wsl_save(dest) is True + assert mock_run.call_count == 2 + assert dest.read_bytes() == FAKE_PNG + def test_no_image_returns_false(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard.subprocess.run") as mock_run: @@ -528,6 +549,16 @@ class TestWindowsHasImage: mock_run.return_value = MagicMock(stdout="False\n", returncode=0) assert _windows_has_image() is False + def test_falls_back_to_get_clipboard_image(self): + with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="False\n", returncode=0), + MagicMock(stdout="True\n", returncode=0), + ] + assert _windows_has_image() is True + assert mock_run.call_count == 2 + def test_no_powershell_available(self): with patch("hermes_cli.clipboard._get_ps_exe", return_value=None): assert _windows_has_image() is False @@ -559,6 +590,20 @@ class TestWindowsSave: assert _windows_save(dest) is True assert dest.read_bytes() == FAKE_PNG + def test_falls_back_to_filedrop_image(self, tmp_path): + dest = tmp_path / "out.png" + b64_png = base64.b64encode(FAKE_PNG).decode() + with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): + with patch("hermes_cli.clipboard.subprocess.run") as mock_run: + mock_run.side_effect = [ + MagicMock(stdout="", returncode=1), + MagicMock(stdout="", returncode=1), + MagicMock(stdout=b64_png + "\n", returncode=0), + ] + assert _windows_save(dest) is True + assert mock_run.call_count == 3 + assert dest.read_bytes() == FAKE_PNG + def test_no_image_returns_false(self, tmp_path): dest = tmp_path / "out.png" with patch("hermes_cli.clipboard._get_ps_exe", return_value="powershell"): diff --git a/ui-tui/packages/hermes-ink/src/ink/dom.ts b/ui-tui/packages/hermes-ink/src/ink/dom.ts index 20b72968aa..6c4b198304 100644 --- a/ui-tui/packages/hermes-ink/src/ink/dom.ts +++ b/ui-tui/packages/hermes-ink/src/ink/dom.ts @@ -436,4 +436,3 @@ export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { node.yogaNode = undefined } - diff --git a/ui-tui/packages/hermes-ink/src/ink/selection.ts b/ui-tui/packages/hermes-ink/src/ink/selection.ts index 03a32b8239..9ee71564e6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/selection.ts +++ b/ui-tui/packages/hermes-ink/src/ink/selection.ts @@ -12,7 +12,7 @@ import { clamp } from './layout/geometry.js' import type { Screen, StylePool } from './screen.js' -import { CellWidth, cellAt, cellAtIndex, setCellStyleId } from './screen.js' +import { cellAt, cellAtIndex, CellWidth, setCellStyleId } from './screen.js' type Point = { col: number; row: number } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 5065c37d64..d7b6e4ae2c 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,7 +20,15 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' -import { buildToolTrailLine, compactPreview, fmtK, hasInterpolation, isToolTrailResultLine, pick, sameToolTrailGroup } from './lib/text.js' +import { + buildToolTrailLine, + compactPreview, + fmtK, + hasInterpolation, + isToolTrailResultLine, + pick, + sameToolTrailGroup +} from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { ActiveTool, @@ -111,7 +119,9 @@ const toTranscriptMessages = (rows: unknown): Msg[] => { let pendingTools: string[] = [] for (const row of rows) { - if (!row || typeof row !== 'object') continue + if (!row || typeof row !== 'object') { + continue + } const role = (row as any).role const text = (row as any).text @@ -120,18 +130,24 @@ const toTranscriptMessages = (rows: unknown): Msg[] => { const name = (row as any).name ?? 'tool' const ctx = (row as any).context ?? '' pendingTools.push(buildToolTrailLine(name, ctx)) + continue } - if (typeof text !== 'string' || !text.trim()) continue + if (typeof text !== 'string' || !text.trim()) { + continue + } if (role === 'assistant') { const msg: Msg = { role, text } + if (pendingTools.length) { msg.tools = pendingTools pendingTools = [] } + result.push(msg) + continue } @@ -2008,6 +2024,7 @@ export function App({ gw }: { gw: GatewayClient }) { { - // Paste hotkey - if ((k.ctrl || k.meta) && inp.toLowerCase() === 'v') { + (inp, k, event) => { + // Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16). + const ctrlPaste = k.ctrl && (inp.toLowerCase() === 'v' || event.keypress.raw === '\x16') + const metaPaste = k.meta && inp.toLowerCase() === 'v' + + if (ctrlPaste || metaPaste) { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index b2b8c3d597..bcee8b7a78 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -48,13 +48,15 @@ export const ToolTrail = memo(function ToolTrail({ tools = [], trail = [], activity = [], - animateCot = false + animateCot = false, + padAfter = false }: { t: Theme tools?: ActiveTool[] trail?: string[] activity?: ActivityItem[] animateCot?: boolean + padAfter?: boolean }) { if (!trail.length && !tools.length && !activity.length) { return null @@ -68,6 +70,7 @@ export const ToolTrail = memo(function ToolTrail({ <> {trail.map((line, i) => { const lastInBlock = i === rowCount - 1 + const suffix = padAfter && lastInBlock ? '\n' : '' if (isToolTrailResultLine(line)) { return ( @@ -78,6 +81,7 @@ export const ToolTrail = memo(function ToolTrail({ > {line} + {suffix} ) } @@ -87,6 +91,7 @@ export const ToolTrail = memo(function ToolTrail({ {line} + {suffix} ) } @@ -95,29 +100,34 @@ export const ToolTrail = memo(function ToolTrail({ {line} + {suffix} ) })} {tools.map((tool, j) => { const lastInBlock = trail.length + j === rowCount - 1 + const suffix = padAfter && lastInBlock ? '\n' : '' return ( {TOOL_VERBS[tool.name] ?? tool.name} {tool.context ? `: ${tool.context}` : ''} + {suffix} ) })} {act.map((item, k) => { const lastInBlock = trail.length + tools.length + k === rowCount - 1 + const suffix = padAfter && lastInBlock ? '\n' : '' return ( {activityGlyph(item)} {item.text} + {suffix} ) })} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 88418d2805..fb42943184 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -39,6 +39,7 @@ export const compactPreview = (s: string, max: number) => { export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { const label = TOOL_VERBS[name] ?? name const mark = error ? '✗' : '✓' + return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` } From 5fb6a4418bdfc7d4af83389892360c6919d48a6c Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Sat, 11 Apr 2026 14:29:24 -0400 Subject: [PATCH 070/157] feat: panels --- tui_gateway/server.py | 103 +++++++++ ui-tui/package-lock.json | 38 ++-- ui-tui/src/app.tsx | 330 ++++++++++++++++++----------- ui-tui/src/components/branding.tsx | 40 +++- ui-tui/src/types.ts | 15 +- 5 files changed, 380 insertions(+), 146 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5f50ab6302..f24a5baf7e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1451,6 +1451,109 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"plugins": []}) +@method("config.show") +def _(rid, params: dict) -> dict: + try: + cfg = _load_cfg() + model = _resolve_model() + api_key = os.environ.get("HERMES_API_KEY", "") or cfg.get("api_key", "") + masked = f"****{api_key[-4:]}" if len(api_key) > 4 else "(not set)" + base_url = os.environ.get("HERMES_BASE_URL", "") or cfg.get("base_url", "") + + sections = [{ + "title": "Model", + "rows": [ + ["Model", model], + ["Base URL", base_url or "(default)"], + ["API Key", masked], + ] + }, { + "title": "Agent", + "rows": [ + ["Max Turns", str(cfg.get("max_turns", 25))], + ["Toolsets", ", ".join(cfg.get("enabled_toolsets", [])) or "all"], + ["Verbose", str(cfg.get("verbose", False))], + ] + }, { + "title": "Environment", + "rows": [ + ["Working Dir", os.getcwd()], + ["Config File", str(_hermes_home / "config.yaml")], + ] + }] + return _ok(rid, {"sections": sections}) + except Exception as e: + return _err(rid, 5030, str(e)) + + +@method("tools.list") +def _(rid, params: dict) -> dict: + try: + from toolsets import get_all_toolsets, get_toolset_info + session = _sessions.get(params.get("session_id", "")) + enabled = set() + if session: + enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) + + items = [] + for name in sorted(get_all_toolsets().keys()): + info = get_toolset_info(name) + if not info: + continue + items.append({ + "name": name, + "description": info["description"], + "tool_count": info["tool_count"], + "enabled": name in enabled if enabled else True, + "tools": info["resolved_tools"], + }) + return _ok(rid, {"toolsets": items}) + except Exception as e: + return _err(rid, 5031, str(e)) + + +@method("toolsets.list") +def _(rid, params: dict) -> dict: + try: + from toolsets import get_all_toolsets, get_toolset_info + session = _sessions.get(params.get("session_id", "")) + enabled = set() + if session: + enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) + + items = [] + for name in sorted(get_all_toolsets().keys()): + info = get_toolset_info(name) + if not info: + continue + items.append({ + "name": name, + "description": info["description"], + "tool_count": info["tool_count"], + "enabled": name in enabled if enabled else True, + }) + return _ok(rid, {"toolsets": items}) + except Exception as e: + return _err(rid, 5032, str(e)) + + +@method("agents.list") +def _(rid, params: dict) -> dict: + try: + from tools.process_registry import ProcessRegistry + procs = ProcessRegistry().list_sessions() + return _ok(rid, { + "processes": [{ + "session_id": p["session_id"], + "command": p["command"][:80], + "status": p["status"], + "uptime": p["uptime_seconds"], + } for p in procs] + }) + except Exception as e: + return _err(rid, 5033, str(e)) + + @method("cron.manage") def _(rid, params: dict) -> dict: action, jid = params.get("action", "list"), params.get("name", "") diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index ec79588fec..a77ca00833 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -88,6 +88,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -317,29 +318,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1464,6 +1442,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1474,6 +1453,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1484,6 +1464,7 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1513,6 +1494,7 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -1830,6 +1812,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2165,6 +2148,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2850,6 +2834,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3745,6 +3730,7 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5085,6 +5071,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5184,6 +5171,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5956,6 +5944,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6082,6 +6071,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6191,6 +6181,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6599,6 +6590,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 5065c37d64..0b82b2fdfe 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -6,7 +6,7 @@ import { join } from 'node:path' import { Box, Text, useApp, useInput, useStdout } from '@hermes/ink' import { useCallback, useEffect, useRef, useState } from 'react' -import { Banner, SessionPanel } from './components/branding.js' +import { Banner, Panel, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' @@ -28,6 +28,7 @@ import type { ApprovalReq, ClarifyReq, Msg, + PanelSection, PasteMode, PendingPaste, SecretReq, @@ -297,7 +298,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) - const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null) + const [pager, setPager] = useState<{ lines: string[]; offset: number; title?: string } | null>(null) // ── Refs ───────────────────────────────────────────────────────── @@ -367,11 +368,18 @@ export function App({ gw }: { gw: GatewayClient }) { const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - const page = useCallback((text: string) => { + const page = useCallback((text: string, title?: string) => { const lines = text.split('\n') - setPager({ lines, offset: 0 }) + setPager({ lines, offset: 0, title }) }, []) + const panel = useCallback( + (title: string, sections: PanelSection[]) => { + appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) + }, + [appendMessage] + ) + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(prev => { const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev @@ -1341,37 +1349,18 @@ export function App({ gw }: { gw: GatewayClient }) { switch (name) { case 'help': { - const cats = catalog?.categories ?? [] - const skills = catalog?.skillCount ?? 0 - const lines: string[] = [] + const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }) => ({ + title: catName, + rows: pairs + })) - for (const { name: catName, pairs } of cats) { - if (lines.length) { - lines.push('') - } - - lines.push(` ${catName}:`) - - for (const [c, d] of pairs) { - lines.push(` ${c.padEnd(18)} ${d}`) - } + if (catalog?.skillCount) { + sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) } - if (!lines.length) { - lines.push(' (no commands loaded)') - } + sections.push({ title: 'Hotkeys', rows: HOTKEYS }) - if (skills > 0) { - lines.push('', ` ${skills} skill commands available — /skills to browse`) - } - - lines.push('', ' Hotkeys:') - - for (const [k, d] of HOTKEYS) { - lines.push(` ${k.padEnd(14)} ${d}`) - } - - sys(lines.join('\n')) + panel('Commands', sections) return true } @@ -1435,16 +1424,16 @@ export function App({ gw }: { gw: GatewayClient }) { } if (arg === 'list') { - sys( - pastes.length - ? pastes - .map( - p => - `#${p.id} ${p.mode} · ${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` - ) - .join('\n') - : 'no text pastes' - ) + if (!pastes.length) { + sys('no text pastes') + } else { + panel('Paste Shelf', [{ + rows: pastes.map(p => [ + `#${p.id} ${p.mode}`, + `${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` + ] as [string, string]) + }]) + } return true } @@ -1497,10 +1486,12 @@ export function App({ gw }: { gw: GatewayClient }) { return true - case 'logs': - sys(gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) || 'no gateway logs') + case 'logs': { + const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) + logText ? page(logText, 'Logs') : sys('no gateway logs') return true + } case 'statusbar': @@ -1606,7 +1597,9 @@ export function App({ gw }: { gw: GatewayClient }) { case 'model': if (!arg) { - rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) + rpc('config.get', { key: 'provider' }).then((r: any) => + panel('Model', [{ rows: [['Model', r.model], ['Provider', r.provider]] }]) + ) } else { rpc('config.set', { key: 'model', value: arg.replace('--global', '').trim() }).then((r: any) => { sys(`model → ${r.value}`) @@ -1618,7 +1611,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => page(r?.output || '(no output)')) + .then((r: any) => page(r?.output || '(no output)', 'Provider')) .catch(() => sys('provider command failed')) return true @@ -1654,7 +1647,7 @@ export function App({ gw }: { gw: GatewayClient }) { ) } else { gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => sys(r?.output || '(no output)')) + .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) .catch(() => sys('personality command failed')) } @@ -1713,30 +1706,30 @@ export function App({ gw }: { gw: GatewayClient }) { } const f = (v: number) => (v ?? 0).toLocaleString() - const ln = (k: string, v: string) => ` ${k.padEnd(26)}${v.padStart(10)}` - const hr = ` ${'─'.repeat(36)}` - const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null - sys( - [ - hr, - ln('Model:', r.model ?? ''), - ln('Input tokens:', f(r.input)), - ln('Cache read tokens:', f(r.cache_read)), - ln('Cache write tokens:', f(r.cache_write)), - ln('Output tokens:', f(r.output)), - ln('Total tokens:', f(r.total)), - ln('API calls:', f(r.calls)), - cost && ln('Cost:', cost), - hr, - r.context_max && ` Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)`, - r.compressions && ` Compressions: ${r.compressions}` - ] - .filter(Boolean) - .join('\n') - ) + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + if (cost) rows.push(['Cost', cost]) + + const sections: PanelSection[] = [{ rows }] + + if (r.context_max) { + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + } + + if (r.compressions) sections.push({ text: `Compressions: ${r.compressions}` }) + + panel('Usage', sections) }) return true @@ -1752,7 +1745,16 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'profile': - rpc('config.get', { key: 'profile' }).then((r: any) => sys(r.display || r.home)) + rpc('config.get', { key: 'profile' }).then((r: any) => { + const text = r.display || r.home + const lines = text.split('\n').filter(Boolean) + + if (lines.length <= 2) { + panel('Profile', [{ text }]) + } else { + page(text, 'Profile') + } + }) return true @@ -1765,7 +1767,13 @@ export function App({ gw }: { gw: GatewayClient }) { case 'insights': rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => - sys(`${r.days}d: ${r.sessions} sessions, ${r.messages} messages`) + panel('Insights', [{ + rows: [ + ['Period', `${r.days} days`], + ['Sessions', `${r.sessions}`], + ['Messages', `${r.messages}`] + ] + }]) ) return true @@ -1778,7 +1786,12 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no checkpoints') } - sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) + panel('Checkpoints', [{ + rows: r.checkpoints.map((c: any, i: number) => [ + `${i} ${c.hash?.slice(0, 8)}`, + c.message + ] as [string, string]) + }]) }) } else { const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub @@ -1805,7 +1818,9 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no plugins') } - sys(r.plugins.map((p: any) => ` ${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`).join('\n')) + panel('Plugins', [{ + items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) + }]) }) return true @@ -1820,43 +1835,31 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no skills installed') } - const lines: string[] = [] - - for (const [cat, names] of Object.entries(sk)) { - lines.push(` ${cat}: ${(names as string[]).join(', ')}`) - } - - sys(lines.join('\n')) + panel('Installed Skills', Object.entries(sk).map(([cat, names]) => ({ + title: cat, + items: names as string[] + }))) }) return true } if (sub === 'browse') { - const page = parseInt(sArgs[0] ?? '1', 10) || 1 - rpc('skills.manage', { action: 'browse', page }).then((r: any) => { - if (!r.items?.length) { - return sys('no skills found in the hub') - } + const pg = parseInt(sArgs[0] ?? '1', 10) || 1 + rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { + if (!r.items?.length) return sys('no skills found in the hub') - const lines = [ - ` Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, - '', - ...r.items.map( - (s: any) => - ` ${(s.name ?? '').padEnd(28)} ${(s.description ?? '').slice(0, 60)}${s.description?.length > 60 ? '…' : ''}` - ) - ] + const sections: PanelSection[] = [{ + rows: r.items.map((s: any) => [ + s.name ?? '', + (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '') + ] as [string, string]) + }] - if (r.page < r.total_pages) { - lines.push('', ` /skills browse ${r.page + 1} → next page`) - } + if (r.page < r.total_pages) sections.push({ text: `/skills browse ${r.page + 1} → next page` }) + if (r.page > 1) sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) - if (r.page > 1) { - lines.push(` /skills browse ${r.page - 1} → prev page`) - } - - sys(lines.join('\n')) + panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) }) return true @@ -1869,6 +1872,94 @@ export function App({ gw }: { gw: GatewayClient }) { return true } + case 'agents': + + case 'tasks': + rpc('agents.list', {}).then((r: any) => { + const procs = r.processes ?? [] + const running = procs.filter((p: any) => p.status === 'running') + const finished = procs.filter((p: any) => p.status !== 'running') + const sections: PanelSection[] = [] + + if (running.length) { + sections.push({ + title: `Running (${running.length})`, + rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } + + if (finished.length) { + sections.push({ + title: `Finished (${finished.length})`, + rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } + + if (!sections.length) sections.push({ text: 'No active processes' }) + + panel('Agents', sections) + }).catch(() => sys('agents command failed')) + + return true + + case 'cron': + if (!arg || arg === 'list') { + rpc('cron.manage', { action: 'list' }).then((r: any) => { + const jobs = r.jobs ?? [] + + if (!jobs.length) return sys('no scheduled jobs') + + panel('Cron', [{ + rows: jobs.map((j: any) => [ + j.name || j.job_id?.slice(0, 12), + `${j.schedule} · ${j.state ?? 'active'}` + ] as [string, string]) + }]) + }).catch(() => sys('cron command failed')) + } else { + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => sys(r?.output || '(no output)')) + .catch(() => sys('cron command failed')) + } + + return true + + case 'config': + rpc('config.show', {}).then((r: any) => { + panel('Config', (r.sections ?? []).map((s: any) => ({ + title: s.title, + rows: s.rows + }))) + }).catch(() => sys('config command failed')) + + return true + + case 'tools': + rpc('tools.list', { session_id: sid }).then((r: any) => { + if (!r.toolsets?.length) return sys('no tools') + + panel('Tools', r.toolsets.map((ts: any) => ({ + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, + items: ts.tools + }))) + }).catch(() => sys('tools command failed')) + + return true + + case 'toolsets': + rpc('toolsets.list', { session_id: sid }).then((r: any) => { + if (!r.toolsets?.length) return sys('no toolsets') + + panel('Toolsets', [{ + rows: r.toolsets.map((ts: any) => [ + `${ts.enabled ? '(*)' : ' '} ${ts.name}`, + `[${ts.tool_count}] ${ts.description}` + ] as [string, string]) + }]) + }).catch(() => sys('toolsets command failed')) + + return true + default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || `/${name}: no output`)) @@ -1892,22 +1983,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } }, - [ - catalog, - compact, - gw, - lastUserMsg, - messages, - newSession, - page, - pastes, - pushActivity, - rpc, - send, - sid, - statusBar, - sys - ] + [catalog, compact, gw, lastUserMsg, messages, newSession, page, panel, pastes, pushActivity, rpc, send, sid, statusBar, sys] ) slashRef.current = slash @@ -1998,6 +2074,8 @@ export function App({ gw }: { gw: GatewayClient }) { + ) : m.kind === 'panel' && m.panelData ? ( + ) : ( )} @@ -2118,16 +2196,26 @@ export function App({ gw }: { gw: GatewayClient }) { )} {pager && ( - + + {pager.title && ( + + + {pager.title} + + + )} + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( {line} ))} - - {pager.offset + pagerPageSize < pager.lines.length - ? `── Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length}) ──` - : `── end · q to close (${pager.lines.length} lines) ──`} - + + + {pager.offset + pagerPageSize < pager.lines.length + ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end · q to close (${pager.lines.length} lines)`} + + )} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 425bfa4d5e..429996db70 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -3,7 +3,7 @@ import { Box, Text, useStdout } from '@hermes/ink' import { artWidth, caduceus, CADUCEUS_WIDTH, logo, LOGO_WIDTH } from '../banner.js' import { flat } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { SessionInfo } from '../types.js' +import type { PanelSection, SessionInfo } from '../types.js' export function ArtLines({ lines }: { lines: [string, string][] }) { return ( @@ -142,3 +142,41 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string ) } + +export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) { + return ( + + + + {title} + + + + {sections.map((sec, si) => ( + 0 ? 1 : 0}> + {sec.title && ( + + {sec.title} + + )} + + {sec.rows?.map(([k, v], ri) => ( + + {k.padEnd(20)} + {v} + + ))} + + {sec.items?.map((item, ii) => ( + + {item} + + ))} + + {sec.text && {sec.text}} + + ))} + + ) +} + diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 0c87b2cc76..eab2969262 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -24,8 +24,9 @@ export interface ClarifyReq { export interface Msg { role: Role text: string - kind?: 'intro' | 'slash' + kind?: 'intro' | 'panel' | 'slash' info?: SessionInfo + panelData?: PanelData thinking?: string tools?: string[] } @@ -62,6 +63,18 @@ export interface SecretReq { requestId: string } +export interface PanelData { + sections: PanelSection[] + title: string +} + +export interface PanelSection { + items?: string[] + rows?: [string, string][] + text?: string + title?: string +} + export type PasteKind = 'code' | 'log' | 'text' export type PasteMode = 'attach' | 'excerpt' | 'inline' From e2ea8934d46a5162ff1dbadbbfe541559e67e734 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 14:02:36 -0500 Subject: [PATCH 071/157] feat: ensure feature parity once again --- tests/test_tui_gateway_server.py | 237 +++++++++++- tui_gateway/server.py | 497 ++++++++++++++++++++++---- ui-tui/src/app.tsx | 256 +++++++++++-- ui-tui/src/components/messageLine.tsx | 12 +- ui-tui/src/components/thinking.tsx | 31 +- ui-tui/src/types.ts | 1 + 6 files changed, 922 insertions(+), 112 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index b519621135..137a5de084 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1,6 +1,9 @@ import json +import sys import threading import time +import types +from pathlib import Path from unittest.mock import patch from tui_gateway import server @@ -30,7 +33,7 @@ class _BrokenStdout: def test_write_json_serializes_concurrent_writes(monkeypatch): out = _ChunkyStdout() - monkeypatch.setattr(server.sys, "stdout", out) + monkeypatch.setattr(server, "_real_stdout", out) threads = [ threading.Thread(target=server.write_json, args=({"seq": i, "text": "x" * 24},)) @@ -50,7 +53,7 @@ def test_write_json_serializes_concurrent_writes(monkeypatch): def test_write_json_returns_false_on_broken_pipe(monkeypatch): - monkeypatch.setattr(server.sys, "stdout", _BrokenStdout()) + monkeypatch.setattr(server, "_real_stdout", _BrokenStdout()) assert server.write_json({"ok": True}) is False @@ -77,3 +80,233 @@ def test_status_callback_accepts_single_message_argument(): "sid", {"kind": "status", "text": "thinking..."}, ) + + +def _session(agent=None, **extra): + return { + "agent": agent if agent is not None else types.SimpleNamespace(), + "session_key": "session-key", + "history": [], + "history_lock": threading.Lock(), + "history_version": 0, + "running": False, + "attached_images": [], + "image_counter": 0, + "cols": 80, + "slash_worker": None, + "show_reasoning": False, + "tool_progress_mode": "all", + **extra, + } + + +def test_config_set_yolo_toggles_session_scope(): + from tools.approval import clear_session, is_session_yolo_enabled + + server._sessions["sid"] = _session() + try: + resp_on = server.handle_request({"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}}) + assert resp_on["result"]["value"] == "1" + assert is_session_yolo_enabled("session-key") is True + + resp_off = server.handle_request({"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "yolo"}}) + assert resp_off["result"]["value"] == "0" + assert is_session_yolo_enabled("session-key") is False + finally: + clear_session("session-key") + server._sessions.clear() + + +def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): + monkeypatch.setattr(server, "_hermes_home", tmp_path) + agent = types.SimpleNamespace(reasoning_config=None) + server._sessions["sid"] = _session(agent=agent) + + resp_effort = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "low"}} + ) + assert resp_effort["result"]["value"] == "low" + assert agent.reasoning_config == {"enabled": True, "effort": "low"} + + resp_show = server.handle_request( + {"id": "2", "method": "config.set", "params": {"session_id": "sid", "key": "reasoning", "value": "show"}} + ) + assert resp_show["result"]["value"] == "show" + assert server._sessions["sid"]["show_reasoning"] is True + + +def test_config_set_verbose_updates_session_mode_and_agent(tmp_path, monkeypatch): + monkeypatch.setattr(server, "_hermes_home", tmp_path) + agent = types.SimpleNamespace(verbose_logging=False) + server._sessions["sid"] = _session(agent=agent) + + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "verbose", "value": "cycle"}} + ) + + assert resp["result"]["value"] == "verbose" + assert server._sessions["sid"]["tool_progress_mode"] == "verbose" + assert agent.verbose_logging is True + + +def test_config_set_model_uses_live_switch_path(monkeypatch): + server._sessions["sid"] = _session() + seen = {} + + def _fake_apply(sid, session, raw): + seen["args"] = (sid, session["session_key"], raw) + return "new/model" + + monkeypatch.setattr(server, "_apply_model_switch", _fake_apply) + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "new/model"}} + ) + + assert resp["result"]["value"] == "new/model" + assert seen["args"] == ("sid", "session-key", "new/model") + + +def test_session_compress_uses_compress_helper(monkeypatch): + agent = types.SimpleNamespace() + server._sessions["sid"] = _session(agent=agent) + + monkeypatch.setattr(server, "_compress_session_history", lambda session: (2, {"total": 42})) + monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) + + with patch("tui_gateway.server._emit") as emit: + resp = server.handle_request({"id": "1", "method": "session.compress", "params": {"session_id": "sid"}}) + + assert resp["result"]["removed"] == 2 + assert resp["result"]["usage"]["total"] == 42 + emit.assert_called_once_with("session.info", "sid", {"model": "x"}) + + +def test_prompt_submit_sets_approval_session_key(monkeypatch): + from tools.approval import get_current_session_key + + captured = {} + + class _Agent: + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + captured["session_key"] = get_current_session_key(default="") + return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]} + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + + resp = server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "ping"}}) + + assert resp["result"]["status"] == "streaming" + assert captured["session_key"] == "session-key" + + +def test_prompt_submit_expands_context_refs(monkeypatch): + captured = {} + + class _Agent: + model = "test/model" + base_url = "" + api_key = "" + + def run_conversation(self, prompt, conversation_history=None, stream_callback=None): + captured["prompt"] = prompt + return {"final_response": "ok", "messages": [{"role": "assistant", "content": "ok"}]} + + class _ImmediateThread: + def __init__(self, target=None, daemon=None): + self._target = target + + def start(self): + self._target() + + fake_ctx = types.ModuleType("agent.context_references") + fake_ctx.preprocess_context_references = lambda message, **kwargs: types.SimpleNamespace( + blocked=False, message="expanded prompt", warnings=[], references=[], injected_tokens=0 + ) + fake_meta = types.ModuleType("agent.model_metadata") + fake_meta.get_model_context_length = lambda *args, **kwargs: 100000 + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr(server.threading, "Thread", _ImmediateThread) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr(server, "make_stream_renderer", lambda cols: None) + monkeypatch.setattr(server, "render_message", lambda raw, cols: None) + monkeypatch.setitem(sys.modules, "agent.context_references", fake_ctx) + monkeypatch.setitem(sys.modules, "agent.model_metadata", fake_meta) + + server.handle_request({"id": "1", "method": "prompt.submit", "params": {"session_id": "sid", "text": "@diff"}}) + + assert captured["prompt"] == "expanded prompt" + + +def test_image_attach_appends_local_image(monkeypatch): + fake_cli = types.ModuleType("cli") + fake_cli._IMAGE_EXTENSIONS = {".png"} + fake_cli._split_path_input = lambda raw: (raw, "") + fake_cli._resolve_attachment_path = lambda raw: Path("/tmp/cat.png") + + server._sessions["sid"] = _session() + monkeypatch.setitem(sys.modules, "cli", fake_cli) + + resp = server.handle_request({"id": "1", "method": "image.attach", "params": {"session_id": "sid", "path": "/tmp/cat.png"}}) + + assert resp["result"]["attached"] is True + assert resp["result"]["name"] == "cat.png" + assert len(server._sessions["sid"]["attached_images"]) == 1 + + +def test_input_detect_drop_attaches_image(monkeypatch): + fake_cli = types.ModuleType("cli") + fake_cli._detect_file_drop = lambda raw: { + "path": Path("/tmp/cat.png"), + "is_image": True, + "remainder": "", + } + + server._sessions["sid"] = _session() + monkeypatch.setitem(sys.modules, "cli", fake_cli) + + resp = server.handle_request( + {"id": "1", "method": "input.detect_drop", "params": {"session_id": "sid", "text": "/tmp/cat.png"}} + ) + + assert resp["result"]["matched"] is True + assert resp["result"]["is_image"] is True + assert resp["result"]["text"] == "[User attached image: cat.png]" + + +def test_rollback_restore_resolves_number_and_file_path(): + calls = {} + + class _Mgr: + enabled = True + + def list_checkpoints(self, cwd): + return [{"hash": "aaa111"}, {"hash": "bbb222"}] + + def restore(self, cwd, target, file_path=None): + calls["args"] = (cwd, target, file_path) + return {"success": True, "message": "done"} + + server._sessions["sid"] = _session(agent=types.SimpleNamespace(_checkpoint_mgr=_Mgr()), history=[]) + resp = server.handle_request( + { + "id": "1", + "method": "rollback.restore", + "params": {"session_id": "sid", "hash": "2", "file_path": "src/app.tsx"}, + } + ) + + assert resp["result"]["success"] is True + assert calls["args"][1] == "bbb222" + assert calls["args"][2] == "src/app.tsx" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 5f50ab6302..ab60f3a0b1 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -229,6 +229,122 @@ def _resolve_model() -> str: return "anthropic/claude-sonnet-4" +def _write_config_key(key_path: str, value): + cfg = _load_cfg() + current = cfg + keys = key_path.split(".") + for key in keys[:-1]: + if key not in current or not isinstance(current.get(key), dict): + current[key] = {} + current = current[key] + current[keys[-1]] = value + _save_cfg(cfg) + + +def _load_reasoning_config() -> dict | None: + from hermes_constants import parse_reasoning_effort + + effort = str(_load_cfg().get("agent", {}).get("reasoning_effort", "") or "").strip() + return parse_reasoning_effort(effort) + + +def _load_service_tier() -> str | None: + raw = str(_load_cfg().get("agent", {}).get("service_tier", "") or "").strip().lower() + if not raw or raw in {"normal", "default", "standard", "off", "none"}: + return None + if raw in {"fast", "priority", "on"}: + return "priority" + return None + + +def _load_show_reasoning() -> bool: + return bool(_load_cfg().get("display", {}).get("show_reasoning", False)) + + +def _load_tool_progress_mode() -> str: + raw = _load_cfg().get("display", {}).get("tool_progress", "all") + if raw is False: + return "off" + if raw is True: + return "all" + mode = str(raw or "all").strip().lower() + return mode if mode in {"off", "new", "all", "verbose"} else "all" + + +def _session_show_reasoning(sid: str) -> bool: + return bool(_sessions.get(sid, {}).get("show_reasoning", False)) + + +def _session_tool_progress_mode(sid: str) -> str: + return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all") + + +def _tool_progress_enabled(sid: str) -> bool: + return _session_tool_progress_mode(sid) != "off" + + +def _restart_slash_worker(session: dict): + worker = session.get("slash_worker") + if worker: + try: + worker.close() + except Exception: + pass + try: + session["slash_worker"] = _SlashWorker(session["session_key"], getattr(session.get("agent"), "model", _resolve_model())) + except Exception: + session["slash_worker"] = None + + +def _apply_model_switch(sid: str, session: dict, raw_input: str) -> str: + agent = session.get("agent") + if not agent: + os.environ["HERMES_MODEL"] = raw_input + return raw_input + + from hermes_cli.model_switch import switch_model + + result = switch_model( + raw_input=raw_input, + current_provider=getattr(agent, "provider", "") or "", + current_model=getattr(agent, "model", "") or "", + current_base_url=getattr(agent, "base_url", "") or "", + current_api_key=getattr(agent, "api_key", "") or "", + ) + if not result.success: + raise ValueError(result.error_message or "model switch failed") + + agent.switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + os.environ["HERMES_MODEL"] = result.new_model + _restart_slash_worker(session) + _emit("session.info", sid, _session_info(agent)) + return result.new_model + + +def _compress_session_history(session: dict) -> tuple[int, dict]: + from agent.model_metadata import estimate_messages_tokens_rough + + agent = session["agent"] + history = list(session.get("history", [])) + if len(history) < 4: + return 0, _get_usage(agent) + approx_tokens = estimate_messages_tokens_rough(history) + compressed, _ = agent._compress_context( + history, + getattr(agent, "_cached_system_prompt", "") or "", + approx_tokens=approx_tokens, + ) + session["history"] = compressed + session["history_version"] = int(session.get("history_version", 0)) + 1 + return len(history) - len(compressed), _get_usage(agent) + + def _get_usage(agent) -> dict: g = lambda k, fb=None: getattr(agent, k, 0) or (getattr(agent, fb, 0) if fb else 0) usage = { @@ -320,14 +436,48 @@ def _tool_ctx(name: str, args: dict) -> str: return "" +def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): + session = _sessions.get(sid) + if session is not None: + try: + from agent.display import capture_local_edit_snapshot + + snapshot = capture_local_edit_snapshot(name, args) + if snapshot is not None: + session.setdefault("edit_snapshots", {})[tool_call_id] = snapshot + except Exception: + pass + if _tool_progress_enabled(sid): + _emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}) + + +def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result: str): + payload = {"tool_id": tool_call_id, "name": name} + session = _sessions.get(sid) + snapshot = None + if session is not None: + snapshot = session.setdefault("edit_snapshots", {}).pop(tool_call_id, None) + try: + from agent.display import render_edit_diff_with_delta + + rendered: list[str] = [] + if render_edit_diff_with_delta(name, result, function_args=args, snapshot=snapshot, print_fn=rendered.append): + payload["inline_diff"] = "\n".join(rendered) + except Exception: + pass + if _tool_progress_enabled(sid) or payload.get("inline_diff"): + _emit("tool.complete", sid, payload) + + def _agent_cbs(sid: str) -> dict: return dict( - tool_start_callback=lambda tc_id, name, args: _emit("tool.start", sid, {"tool_id": tc_id, "name": name, "context": _tool_ctx(name, args)}), - 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}), + tool_start_callback=lambda tc_id, name, args: _on_tool_start(sid, tc_id, name, args), + tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete(sid, tc_id, name, args, result), + tool_progress_callback=lambda name, preview, args: _tool_progress_enabled(sid) + and _emit("tool.progress", sid, {"name": name, "preview": preview}), + tool_gen_callback=lambda name: _tool_progress_enabled(sid) and _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}), + reasoning_callback=lambda text: _session_show_reasoning(sid) and _emit("reasoning.delta", sid, {"text": text}), status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)), clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), ) @@ -357,7 +507,12 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): cfg = _load_cfg() system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" return AIAgent( - model=_resolve_model(), quiet_mode=True, platform="tui", + model=_resolve_model(), + quiet_mode=True, + verbose_logging=_load_tool_progress_mode() == "verbose", + reasoning_config=_load_reasoning_config(), + service_tier=_load_service_tier(), + platform="tui", session_id=session_id or key, session_db=_get_db(), ephemeral_system_prompt=system_prompt or None, **_agent_cbs(sid), @@ -369,10 +524,16 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "agent": agent, "session_key": key, "history": history, + "history_lock": threading.Lock(), + "history_version": 0, + "running": False, "attached_images": [], "image_counter": 0, "cols": cols, "slash_worker": None, + "show_reasoning": _load_show_reasoning(), + "tool_progress_mode": _load_tool_progress_mode(), + "edit_snapshots": {}, } try: _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) @@ -397,6 +558,17 @@ def _with_checkpoints(session, fn): return fn(session["agent"]._checkpoint_mgr, os.getenv("TERMINAL_CWD", os.getcwd())) +def _resolve_checkpoint_hash(mgr, cwd: str, ref: str) -> str: + try: + checkpoints = mgr.list_checkpoints(cwd) + idx = int(ref) - 1 + except ValueError: + return ref + if 0 <= idx < len(checkpoints): + return checkpoints[idx].get("hash", ref) + raise ValueError(f"Invalid checkpoint number. Use 1-{len(checkpoints)}.") + + def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: """Pre-analyze attached images via vision and prepend descriptions to user text.""" import asyncio, json as _json @@ -561,11 +733,17 @@ def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err - history, removed = session.get("history", []), 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 + removed = 0 + with session["history_lock"]: + history = session.get("history", []) + 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 + if removed: + session["history_version"] = int(session.get("history_version", 0)) + 1 return _ok(rid, {"removed": removed}) @@ -574,11 +752,11 @@ def _(rid, params: dict) -> dict: session, err = _sess(params, rid) if err: return err - agent = session["agent"] try: - if hasattr(agent, "compress_context"): - agent.compress_context() - return _ok(rid, {"status": "compressed", "usage": _get_usage(agent)}) + with session["history_lock"]: + removed, usage = _compress_session_history(session) + _emit("session.info", params.get("session_id", ""), _session_info(session["agent"])) + return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage}) except Exception as e: return _err(rid, 5005, str(e)) @@ -606,7 +784,8 @@ def _(rid, params: dict) -> dict: return err db = _get_db() old_key = session["session_key"] - history = session.get("history", []) + with session["history_lock"]: + history = [dict(msg) for msg in session.get("history", [])] if not history: return _err(rid, 4008, "nothing to branch — send a message first") new_key = _new_session_key() @@ -666,15 +845,47 @@ def _(rid, params: dict) -> dict: session = _sessions.get(sid) if not session: return _err(rid, 4001, "session not found") - agent, history = session["agent"], session["history"] + with session["history_lock"]: + if session.get("running"): + return _err(rid, 4009, "session busy") + session["running"] = True + history = list(session["history"]) + history_version = int(session.get("history_version", 0)) + images = list(session.get("attached_images", [])) + session["attached_images"] = [] + agent = session["agent"] _emit("message.start", sid) def run(): + approval_token = None try: + from tools.approval import reset_current_session_key, set_current_session_key + approval_token = set_current_session_key(session["session_key"]) cols = session.get("cols", 80) streamer = make_stream_renderer(cols) - images = session.pop("attached_images", []) - prompt = _enrich_with_attached_images(text, images) if images else text + prompt = text + + if isinstance(prompt, str) and "@" in prompt: + from agent.context_references import preprocess_context_references + from agent.model_metadata import get_model_context_length + + ctx_len = get_model_context_length( + getattr(agent, "model", "") or _resolve_model(), + base_url=getattr(agent, "base_url", "") or "", + api_key=getattr(agent, "api_key", "") or "", + ) + ctx = preprocess_context_references( + prompt, + cwd=os.environ.get("TERMINAL_CWD", os.getcwd()), + allowed_root=os.environ.get("TERMINAL_CWD", os.getcwd()), + context_length=ctx_len, + ) + if ctx.blocked: + _emit("error", sid, {"message": "\n".join(ctx.warnings) or "Context injection refused."}) + return + prompt = ctx.message + + prompt = _enrich_with_attached_images(prompt, images) if images else prompt def _stream(delta): payload = {"text": delta} @@ -689,7 +900,10 @@ def _(rid, params: dict) -> dict: if isinstance(result, dict): if isinstance(result.get("messages"), list): - session["history"] = result["messages"] + with session["history_lock"]: + if int(session.get("history_version", 0)) == history_version: + session["history"] = result["messages"] + session["history_version"] = history_version + 1 raw = result.get("final_response", "") status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" else: @@ -703,6 +917,14 @@ def _(rid, params: dict) -> dict: _emit("message.complete", sid, payload) except Exception as e: _emit("error", sid, {"message": str(e)}) + finally: + try: + if approval_token is not None: + reset_current_session_key(approval_token) + except Exception: + pass + with session["history_lock"]: + session["running"] = False threading.Thread(target=run, daemon=True).start() return _ok(rid, {"status": "streaming"}) @@ -733,6 +955,84 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"attached": True, "path": str(img_path), "count": len(session["attached_images"])}) +@method("image.attach") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + raw = str(params.get("path", "") or "").strip() + if not raw: + return _err(rid, 4015, "path required") + try: + from cli import _IMAGE_EXTENSIONS, _resolve_attachment_path, _split_path_input + + path_token, remainder = _split_path_input(raw) + image_path = _resolve_attachment_path(path_token) + if image_path is None: + return _err(rid, 4016, f"image not found: {path_token}") + if image_path.suffix.lower() not in _IMAGE_EXTENSIONS: + return _err(rid, 4016, f"unsupported image: {image_path.name}") + session.setdefault("attached_images", []).append(str(image_path)) + return _ok( + rid, + { + "attached": True, + "path": str(image_path), + "name": image_path.name, + "count": len(session["attached_images"]), + "remainder": remainder, + "text": remainder or f"[User attached image: {image_path.name}]", + }, + ) + except Exception as e: + return _err(rid, 5027, str(e)) + + +@method("input.detect_drop") +def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err + try: + from cli import _detect_file_drop + + raw = str(params.get("text", "") or "") + dropped = _detect_file_drop(raw) + if not dropped: + return _ok(rid, {"matched": False}) + + drop_path = dropped["path"] + remainder = dropped["remainder"] + if dropped["is_image"]: + session.setdefault("attached_images", []).append(str(drop_path)) + text = remainder or f"[User attached image: {drop_path.name}]" + return _ok( + rid, + { + "matched": True, + "is_image": True, + "path": str(drop_path), + "name": drop_path.name, + "count": len(session["attached_images"]), + "text": text, + }, + ) + + text = f"[User attached file: {drop_path}]" + (f"\n{remainder}" if remainder else "") + return _ok( + rid, + { + "matched": True, + "is_image": False, + "path": str(drop_path), + "name": drop_path.name, + "text": text, + }, + ) + except Exception as e: + return _err(rid, 5027, str(e)) + + @method("prompt.background") def _(rid, params: dict) -> dict: text, parent = params.get("text", ""), params.get("session_id", "") @@ -819,39 +1119,94 @@ def _(rid, params: dict) -> dict: @method("config.set") def _(rid, params: dict) -> dict: key, value = params.get("key", ""), params.get("value", "") + session = _sessions.get(params.get("session_id", "")) if key == "model": - os.environ["HERMES_MODEL"] = value - return _ok(rid, {"key": key, "value": value}) + try: + if not value: + return _err(rid, 4002, "model value required") + if session: + value = _apply_model_switch(params.get("session_id", ""), session, value) + else: + os.environ["HERMES_MODEL"] = value + return _ok(rid, {"key": key, "value": value}) + except Exception as e: + return _err(rid, 5001, str(e)) if key == "verbose": cycle = ["off", "new", "all", "verbose"] + cur = session.get("tool_progress_mode", _load_tool_progress_mode()) if session else _load_tool_progress_mode() if value and value != "cycle": - os.environ["HERMES_VERBOSE"] = value - return _ok(rid, {"key": key, "value": value}) - cur = os.environ.get("HERMES_VERBOSE", "all") - try: - idx = cycle.index(cur) - except ValueError: - idx = 2 - nv = cycle[(idx + 1) % len(cycle)] - os.environ["HERMES_VERBOSE"] = nv + nv = str(value).strip().lower() + if nv not in cycle: + return _err(rid, 4002, f"unknown verbose mode: {value}") + else: + try: + idx = cycle.index(cur) + except ValueError: + idx = 2 + nv = cycle[(idx + 1) % len(cycle)] + _write_config_key("display.tool_progress", nv) + if session: + session["tool_progress_mode"] = nv + agent = session.get("agent") + if agent is not None: + agent.verbose_logging = nv == "verbose" return _ok(rid, {"key": key, "value": nv}) if key == "yolo": - nv = "0" if os.environ.get("HERMES_YOLO", "0") == "1" else "1" - os.environ["HERMES_YOLO"] = nv - return _ok(rid, {"key": key, "value": nv}) + try: + if session: + from tools.approval import ( + disable_session_yolo, + enable_session_yolo, + is_session_yolo_enabled, + ) + + current = is_session_yolo_enabled(session["session_key"]) + if current: + disable_session_yolo(session["session_key"]) + nv = "0" + else: + enable_session_yolo(session["session_key"]) + nv = "1" + else: + current = bool(os.environ.get("HERMES_YOLO_MODE")) + if current: + os.environ.pop("HERMES_YOLO_MODE", None) + nv = "0" + else: + os.environ["HERMES_YOLO_MODE"] = "1" + nv = "1" + return _ok(rid, {"key": key, "value": nv}) + except Exception as e: + return _err(rid, 5001, str(e)) if key == "reasoning": - if value in ("show", "on"): - os.environ["HERMES_SHOW_REASONING"] = "1" - return _ok(rid, {"key": key, "value": "show"}) - if value in ("hide", "off"): - os.environ.pop("HERMES_SHOW_REASONING", None) - return _ok(rid, {"key": key, "value": "hide"}) - os.environ["HERMES_REASONING"] = value - return _ok(rid, {"key": key, "value": value}) + try: + from hermes_constants import parse_reasoning_effort + + arg = str(value or "").strip().lower() + if arg in ("show", "on"): + _write_config_key("display.show_reasoning", True) + if session: + session["show_reasoning"] = True + return _ok(rid, {"key": key, "value": "show"}) + if arg in ("hide", "off"): + _write_config_key("display.show_reasoning", False) + if session: + session["show_reasoning"] = False + return _ok(rid, {"key": key, "value": "hide"}) + + parsed = parse_reasoning_effort(arg) + if parsed is None: + return _err(rid, 4002, f"unknown reasoning value: {value}") + _write_config_key("agent.reasoning_effort", arg) + if session and session.get("agent") is not None: + session["agent"].reasoning_config = parsed + return _ok(rid, {"key": key, "value": arg}) + except Exception as e: + return _err(rid, 5001, str(e)) if key in ("prompt", "personality", "skin"): try: @@ -900,6 +1255,12 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) + if key == "mtime": + cfg_path = _hermes_home / "config.yaml" + try: + return _ok(rid, {"mtime": cfg_path.stat().st_mtime if cfg_path.exists() else 0}) + except Exception: + return _ok(rid, {"mtime": 0}) return _err(rid, 4002, f"unknown config key: {key}") @@ -1235,30 +1596,23 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str): try: if name == "model" and arg and agent: - from hermes_cli.model_switch import switch_model - result = switch_model( - raw_input=arg, - current_provider=getattr(agent, "provider", "") or "", - current_model=getattr(agent, "model", "") or "", - current_base_url=getattr(agent, "base_url", "") or "", - current_api_key=getattr(agent, "api_key", "") or "", - ) - if result.success: - agent.switch_model( - new_model=result.new_model, - new_provider=result.target_provider, - api_key=result.api_key, - base_url=result.base_url, - api_mode=result.api_mode, - ) - _emit("session.info", sid, _session_info(agent)) + _apply_model_switch(sid, session, arg) elif name in ("personality", "prompt") and agent: cfg = _load_cfg() new_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" agent.ephemeral_system_prompt = new_prompt or None agent._cached_system_prompt = None elif name == "compress" and agent: - (getattr(agent, "compress_context", None) or getattr(agent, "context_compressor", agent).compress)() + with session["history_lock"]: + _compress_session_history(session) + _emit("session.info", sid, _session_info(agent)) + elif name == "fast" and agent: + mode = arg.lower() + if mode in {"fast", "on"}: + agent.service_tier = "priority" + elif mode in {"normal", "off"}: + agent.service_tier = None + _emit("session.info", sid, _session_info(agent)) elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): agent.reload_mcp_tools() elif name == "stop": @@ -1384,10 +1738,29 @@ def _(rid, params: dict) -> dict: if err: return err target = params.get("hash", "") + file_path = params.get("file_path", "") if not target: return _err(rid, 4014, "hash required") try: - return _ok(rid, _with_checkpoints(session, lambda mgr, cwd: mgr.restore(cwd, target))) + def go(mgr, cwd): + resolved = _resolve_checkpoint_hash(mgr, cwd, target) + result = mgr.restore(cwd, resolved, file_path=file_path or None) + if result.get("success") and not file_path: + removed = 0 + with session["history_lock"]: + history = session.get("history", []) + 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 + if removed: + session["history_version"] = int(session.get("history_version", 0)) + 1 + result["history_removed"] = removed + return result + + return _ok(rid, _with_checkpoints(session, go)) except Exception as e: return _err(rid, 5021, str(e)) @@ -1401,7 +1774,7 @@ def _(rid, params: dict) -> dict: if not target: return _err(rid, 4014, "hash required") try: - r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, target)) + r = _with_checkpoints(session, lambda mgr, cwd: mgr.diff(cwd, _resolve_checkpoint_hash(mgr, cwd, target))) raw = r.get("diff", "")[:4000] payload = {"stat": r.get("stat", ""), "diff": raw} rendered = render_diff(raw, session.get("cols", 80)) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index d7b6e4ae2c..cfe7b7d21f 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -189,6 +189,23 @@ function ctxBar(pct: number | undefined, w = 10) { return '█'.repeat(filled) + '░'.repeat(w - filled) } +function fmtDuration(ms: number) { + const total = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(total / 3600) + const mins = Math.floor((total % 3600) / 60) + const secs = total % 60 + + if (hours > 0) { + return `${hours}h ${mins}m` + } + + if (mins > 0) { + return `${mins}m ${secs}s` + } + + return `${secs}s` +} + function StatusRule({ cols, status, @@ -196,6 +213,8 @@ function StatusRule({ model, usage, bgCount, + durationLabel, + voiceLabel, t }: { cols: number @@ -204,6 +223,8 @@ function StatusRule({ model: string usage: Usage bgCount: number + durationLabel?: string + voiceLabel?: string t: Theme }) { const pct = usage.context_percent @@ -218,9 +239,16 @@ function StatusRule({ const pctLabel = pct != null ? `${pct}%` : '' const bar = usage.context_max ? ctxBar(pct) : '' - const segs = [status, model, ctxLabel, bar ? `[${bar}]` : '', pctLabel, bgCount > 0 ? `${bgCount} bg` : ''].filter( - Boolean - ) + const segs = [ + status, + model, + ctxLabel, + bar ? `[${bar}]` : '', + pctLabel, + durationLabel || '', + voiceLabel || '', + bgCount > 0 ? `${bgCount} bg` : '' + ].filter(Boolean) const inner = segs.join(' │ ') const pad = Math.max(0, cols - inner.length - 5) @@ -237,6 +265,8 @@ function StatusRule({ [{bar}] {pctLabel} ) : null} + {durationLabel ? │ {durationLabel} : null} + {voiceLabel ? │ {voiceLabel} : null} {bgCount > 0 ? │ {bgCount} bg : null} {' ' + '─'.repeat(pad)} @@ -314,6 +344,12 @@ export function App({ gw }: { gw: GatewayClient }) { const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) const [pager, setPager] = useState<{ lines: string[]; offset: number } | null>(null) + const [voiceEnabled, setVoiceEnabled] = useState(false) + const [voiceRecording, setVoiceRecording] = useState(false) + const [voiceProcessing, setVoiceProcessing] = useState(false) + const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [bellOnComplete, setBellOnComplete] = useState(false) + const [clockNow, setClockNow] = useState(() => Date.now()) // ── Refs ───────────────────────────────────────────────────────── @@ -333,6 +369,7 @@ export function App({ gw }: { gw: GatewayClient }) { const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) + const configMtimeRef = useRef(0) colsRef.current = cols busyRef.current = busy reasoningRef.current = reasoning @@ -367,6 +404,12 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + const id = setInterval(() => setClockNow(Date.now()), 1000) + + return () => clearInterval(id) + }, []) + // ── Core actions ───────────────────────────────────────────────── const appendMessage = useCallback((msg: Msg) => { @@ -423,6 +466,44 @@ export function App({ gw }: { gw: GatewayClient }) { [gw, sys] ) + useEffect(() => { + if (!sid) { + return + } + + rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) + rpc('config.get', { key: 'mtime' }).then((r: any) => { + configMtimeRef.current = Number(r?.mtime ?? 0) + }) + rpc('config.get', { key: 'full' }).then((r: any) => { + setBellOnComplete(!!r?.config?.display?.bell_on_complete) + }) + }, [rpc, sid]) + + useEffect(() => { + if (!sid) { + return + } + + const id = setInterval(() => { + rpc('config.get', { key: 'mtime' }).then((r: any) => { + const next = Number(r?.mtime ?? 0) + + if (configMtimeRef.current && next && next !== configMtimeRef.current) { + configMtimeRef.current = next + rpc('reload.mcp', { session_id: sid }).then(() => pushActivity('MCP reloaded after config change')) + rpc('config.get', { key: 'full' }).then((cfg: any) => { + setBellOnComplete(!!cfg?.config?.display?.bell_on_complete) + }) + } else if (!configMtimeRef.current && next) { + configMtimeRef.current = next + } + }) + }, 5000) + + return () => clearInterval(id) + }, [pushActivity, rpc, sid]) + const idle = () => { setThinking(false) setTools([]) @@ -454,6 +535,8 @@ export function App({ gw }: { gw: GatewayClient }) { const resetSession = () => { idle() setReasoning('') + setVoiceRecording(false) + setVoiceProcessing(false) setSid(null as any) // will be set by caller setHistoryItems([]) setMessages([]) @@ -477,6 +560,7 @@ export function App({ gw }: { gw: GatewayClient }) { resetSession() setSid(r.session_id) + setSessionStartedAt(Date.now()) setStatus('ready') if (r.info) { @@ -506,6 +590,7 @@ export function App({ gw }: { gw: GatewayClient }) { .then((r: any) => { resetSession() setSid(r.session_id) + setSessionStartedAt(Date.now()) setInfo(r.info ?? null) const resumed = toTranscriptMessages(r.messages) @@ -667,25 +752,45 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') } - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - statusTimerRef.current = null + const startSubmit = (displayText: string, submitText: string) => { + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + statusTimerRef.current = null + } + + inflightPasteIdsRef.current = payload.usedIds + setLastUserMsg(text) + appendMessage({ role: 'user', text: displayText }) + setBusy(true) + setStatus('running…') + buf.current = '' + interruptedRef.current = false + + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + inflightPasteIdsRef.current = [] + sys(`error: ${e.message}`) + setStatus('ready') + setBusy(false) + }) } - inflightPasteIdsRef.current = payload.usedIds - setLastUserMsg(text) - appendMessage({ role: 'user', text }) - setBusy(true) - setStatus('running…') - buf.current = '' - interruptedRef.current = false + gw.request('input.detect_drop', { session_id: sid, text: payload.text }) + .then((r: any) => { + if (r?.matched) { + if (r.is_image) { + pushActivity(`attached image: ${r.name}`) + } else { + pushActivity(`detected file: ${r.name}`) + } - gw.request('prompt.submit', { session_id: sid, text: payload.text }).catch((e: Error) => { - inflightPasteIdsRef.current = [] - sys(`error: ${e.message}`) - setStatus('ready') - setBusy(false) - }) + startSubmit(r.text || text, r.text || payload.text) + + return + } + + startSubmit(text, payload.text) + }) + .catch(() => startSubmit(text, payload.text)) } const shellExec = (cmd: string) => { @@ -1027,6 +1132,37 @@ export function App({ gw }: { gw: GatewayClient }) { return } + if (ctrl(key, ch, 'b')) { + if (voiceRecording) { + setVoiceRecording(false) + setVoiceProcessing(true) + rpc('voice.record', { action: 'stop' }) + .then((r: any) => { + const transcript = String(r?.text || '').trim() + + if (transcript) { + setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript)) + } else { + sys('voice: no speech detected') + } + }) + .catch((e: Error) => sys(`voice error: ${e.message}`)) + .finally(() => { + setVoiceProcessing(false) + setStatus('ready') + }) + } else { + rpc('voice.record', { action: 'start' }) + .then(() => { + setVoiceRecording(true) + setStatus('recording…') + }) + .catch((e: Error) => sys(`voice error: ${e.message}`)) + } + + return + } + if (ctrl(key, ch, 'g')) { return openEditor() } @@ -1184,7 +1320,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.start': - setTools(prev => [...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '' }]) + setTools(prev => [ + ...prev, + { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } + ]) break case 'tool.complete': { @@ -1211,6 +1350,10 @@ export function App({ gw }: { gw: GatewayClient }) { return remaining }) + if (p?.inline_diff) { + sys(p.inline_diff as string) + } + break } @@ -1262,7 +1405,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.delta': if (p?.text && !interruptedRef.current) { - buf.current += p.rendered ?? p.text + buf.current = p.rendered ?? buf.current + p.text setStreaming(buf.current.trimStart()) } @@ -1289,6 +1432,10 @@ export function App({ gw }: { gw: GatewayClient }) { thinking: savedReasoning || undefined, tools: savedTools.length ? savedTools : undefined }) + + if (bellOnComplete && stdout?.isTTY) { + stdout.write('\x07') + } } turnToolsRef.current = [] @@ -1624,14 +1771,31 @@ export function App({ gw }: { gw: GatewayClient }) { if (!arg) { rpc('config.get', { key: 'provider' }).then((r: any) => sys(`${r.model} (${r.provider})`)) } else { - rpc('config.set', { key: 'model', value: arg.replace('--global', '').trim() }).then((r: any) => { - sys(`model → ${r.value}`) - setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) - }) + rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( + (r: any) => { + sys(`model → ${r.value}`) + setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) + } + ) } return true + case 'image': + rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { + if (!r) { + return + } + + sys(`attached image: ${r.name}`) + + if (r?.remainder) { + setInput(r.remainder) + } + }) + + return true + case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) .then((r: any) => page(r?.output || '(no output)')) @@ -1649,17 +1813,23 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'yolo': - rpc('config.set', { key: 'yolo' }).then((r: any) => sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)) + rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => + sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) + ) return true case 'reasoning': - rpc('config.set', { key: 'reasoning', value: arg || 'medium' }).then((r: any) => sys(`reasoning: ${r.value}`)) + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => + sys(`reasoning: ${r.value}`) + ) return true case 'verbose': - rpc('config.set', { key: 'verbose', value: arg || 'cycle' }).then((r: any) => sys(`verbose: ${r.value}`)) + rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => + sys(`verbose: ${r.value}`) + ) return true @@ -1694,6 +1864,7 @@ export function App({ gw }: { gw: GatewayClient }) { rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { if (r?.session_id) { setSid(r.session_id) + setSessionStartedAt(Date.now()) setHistoryItems([]) setMessages([]) sys(`branched → ${r.title}`) @@ -1773,9 +1944,14 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'voice': - rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => + rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { + if (!r) { + return + } + + setVoiceEnabled(!!r?.enabled) sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`) - ) + }) return true @@ -1794,13 +1970,19 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no checkpoints') } - sys(r.checkpoints.map((c: any, i: number) => ` ${i} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) + sys(r.checkpoints.map((c: any, i: number) => ` ${i + 1} ${c.hash?.slice(0, 8)} ${c.message}`).join('\n')) }) } else { const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub - rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { session_id: sid, hash }).then((r: any) => - sys(r.rendered || r.diff || r.message || 'done') - ) + + const filePath = + sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim() + + rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { + session_id: sid, + hash, + ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) + }).then((r: any) => sys(r.rendered || r.diff || r.message || 'done')) } return true @@ -2003,6 +2185,9 @@ export function App({ gw }: { gw: GatewayClient }) { ? theme.color.warn : theme.color.dim + const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : '' + const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` + // ── Render ─────────────────────────────────────────────────────── return ( @@ -2024,7 +2209,6 @@ export function App({ gw }: { gw: GatewayClient }) { )} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 5bf70b0a53..b32f03cd78 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -39,8 +39,12 @@ export const MessageLine = memo(function MessageLine({ return {msg.text} } + if (msg.role !== 'user' && hasAnsi(msg.text)) { + return {msg.text} + } + if (msg.role === 'assistant') { - return hasAnsi(msg.text) ? {msg.text} : + return } if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) { @@ -63,7 +67,11 @@ export const MessageLine = memo(function MessageLine({ })() return ( - + {msg.thinking && ( 💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index bcee8b7a78..9ec53ddc0d 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -25,6 +25,12 @@ const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : i const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '} +const fmtElapsed = (ms: number) => { + const sec = Math.max(0, ms) / 1000 + + return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` +} + export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const [spin] = useState(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] @@ -48,16 +54,26 @@ export const ToolTrail = memo(function ToolTrail({ tools = [], trail = [], activity = [], - animateCot = false, - padAfter = false + animateCot = false }: { t: Theme tools?: ActiveTool[] trail?: string[] activity?: ActivityItem[] animateCot?: boolean - padAfter?: boolean }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!tools.length) { + return + } + + const id = setInterval(() => setNow(Date.now()), 200) + + return () => clearInterval(id) + }, [tools.length]) + if (!trail.length && !tools.length && !activity.length) { return null } @@ -70,7 +86,6 @@ export const ToolTrail = memo(function ToolTrail({ <> {trail.map((line, i) => { const lastInBlock = i === rowCount - 1 - const suffix = padAfter && lastInBlock ? '\n' : '' if (isToolTrailResultLine(line)) { return ( @@ -81,7 +96,6 @@ export const ToolTrail = memo(function ToolTrail({ > {line} - {suffix} ) } @@ -91,7 +105,6 @@ export const ToolTrail = memo(function ToolTrail({ {line} - {suffix} ) } @@ -100,34 +113,30 @@ export const ToolTrail = memo(function ToolTrail({ {line} - {suffix} ) })} {tools.map((tool, j) => { const lastInBlock = trail.length + j === rowCount - 1 - const suffix = padAfter && lastInBlock ? '\n' : '' return ( {TOOL_VERBS[tool.name] ?? tool.name} {tool.context ? `: ${tool.context}` : ''} - {suffix} + {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) })} {act.map((item, k) => { const lastInBlock = trail.length + tools.length + k === rowCount - 1 - const suffix = padAfter && lastInBlock ? '\n' : '' return ( {activityGlyph(item)} {item.text} - {suffix} ) })} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 0c87b2cc76..164123244b 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -2,6 +2,7 @@ export interface ActiveTool { id: string name: string context?: string + startedAt?: number } export interface ActivityItem { From 5e5e65f6d5b11e4df95c29d47fde198a0405cae1 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Sat, 11 Apr 2026 15:30:37 -0400 Subject: [PATCH 072/157] fix nix build --- flake.lock | 6 +++--- nix/tui.nix | 4 ++-- ui-tui/package-lock.json | 38 +++++++++++++++++++++++--------------- 3 files changed, 28 insertions(+), 20 deletions(-) diff --git a/flake.lock b/flake.lock index ad530ea1d3..305b79526e 100644 --- a/flake.lock +++ b/flake.lock @@ -43,11 +43,11 @@ ] }, "locked": { - "lastModified": 1734767823, - "narHash": "sha256-UHVfdyuOdGEDRPkoSJRsX7HhN8oL/g903QUlzhBTadI=", + "lastModified": 1775903712, + "narHash": "sha256-2GV79U6iVH4gKAPWYrxUReB0S41ty/Y3dBLquU8AlaA=", "owner": "jeslie0", "repo": "npm-lockfile-fix", - "rev": "193e463bf27a36f85775eddde7189f93a493d2b3", + "rev": "c6093acb0c0548e0f9b8b3d82918823721930fe8", "type": "github" }, "original": { diff --git a/nix/tui.nix b/nix/tui.nix index 5b0ea32682..a077dc2d43 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-zFGNrlB07I5MwF+Fo4Jf/MZnKIFzkfD+MoL+svt6Fr0="; + hash = "sha256-QQixyLmsn5+Y1daHifzDaNQbaoZjm+ezGrGoLXcc95U="; }; packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json")); @@ -46,7 +46,7 @@ pkgs.buildNpmPackage { rm -rf node_modules/ npm cache clean --force npm install - ${pkgs.lib.getExe' npm-lockfile-fix "npm-lockfile-fix"} ./package-lock.json + ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json NIX_FILE="$REPO_ROOT/nix/tui.nix" # compute the new hash diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index a77ca00833..ec79588fec 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -88,7 +88,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -318,6 +317,29 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1442,7 +1464,6 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1453,7 +1474,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1464,7 +1484,6 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1494,7 +1513,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -1812,7 +1830,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2148,7 +2165,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2834,7 +2850,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3730,7 +3745,6 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", - "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5071,7 +5085,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5171,7 +5184,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5944,7 +5956,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6071,7 +6082,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6181,7 +6191,6 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6590,7 +6599,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 32302c37dd41a1a43bdfaf3b55a4700b821fdcae Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 14:42:28 -0500 Subject: [PATCH 073/157] feat: fix types and add type checking plus lazybundle on launch andddd dev flag --- hermes_cli/main.py | 146 +++- tests/hermes_cli/test_tui_resume_flow.py | 6 +- ui-tui/bun.lock | 756 ---------------- ui-tui/eslint.config.mjs | 14 +- ui-tui/package-lock.json | 487 +++++++++++ ui-tui/package.json | 5 +- ui-tui/packages/hermes-ink/ambient.d.ts | 83 ++ ui-tui/packages/hermes-ink/index.d.ts | 1 + ui-tui/packages/hermes-ink/index.js | 26 +- ui-tui/packages/hermes-ink/package-lock.json | 819 ++++++++++++++++++ ui-tui/packages/hermes-ink/package.json | 16 +- .../packages/hermes-ink/src/entry-exports.ts | 25 + ui-tui/packages/hermes-ink/src/ink/Ansi.tsx | 19 +- .../src/ink/components/AlternateScreen.tsx | 2 +- .../hermes-ink/src/ink/components/Box.tsx | 5 +- .../src/ink/components/ClockContext.tsx | 4 +- .../hermes-ink/src/ink/components/Link.tsx | 2 +- .../hermes-ink/src/ink/components/Newline.tsx | 2 +- .../src/ink/components/NoSelect.tsx | 2 +- .../hermes-ink/src/ink/components/RawAnsi.tsx | 2 +- .../src/ink/components/ScrollBox.tsx | 2 +- .../ink/components/TerminalFocusContext.tsx | 4 +- .../hermes-ink/src/ink/components/Text.tsx | 2 +- .../packages/hermes-ink/src/ink/devtools.ts | 2 + .../hermes-ink/src/ink/events/paste-event.ts | 10 + .../hermes-ink/src/ink/events/resize-event.ts | 12 + ui-tui/packages/hermes-ink/src/ink/ink.tsx | 13 +- .../packages/hermes-ink/src/ink/reconciler.ts | 31 +- .../hermes-ink/src/ink/render-to-screen.ts | 5 - .../packages/hermes-ink/src/utils/semver.ts | 2 +- ui-tui/src/app.tsx | 267 ++++-- ui-tui/src/components/branding.tsx | 1 - ui-tui/src/components/textInput.tsx | 3 +- ui-tui/src/types/hermes-ink.d.ts | 8 +- 34 files changed, 1807 insertions(+), 977 deletions(-) delete mode 100644 ui-tui/bun.lock create mode 100644 ui-tui/packages/hermes-ink/ambient.d.ts create mode 100644 ui-tui/packages/hermes-ink/package-lock.json create mode 100644 ui-tui/packages/hermes-ink/src/entry-exports.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/devtools.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/paste-event.ts create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/resize-event.ts diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 577aa67a74..f4cf321f08 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -606,18 +606,58 @@ def _print_tui_exit_summary(session_id: Optional[str]) -> None: ) -def _find_bundled_tui() -> Optional[Path]: - """Find a bundled copy of the TUI. - Does *not* read from the `npm run build` dist dir, - as this would be a footgun when developing - """ - bundled_tui_dir = os.environ.get("HERMES_TUI_DIR") - if bundled_tui_dir and (Path(bundled_tui_dir) / "dist" / "entry.js").exists(): - return Path(bundled_tui_dir) +def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: + """Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui.""" + env = os.environ.get("HERMES_TUI_DIR") + if env: + p = Path(env) + if (p / "dist" / "entry.js").exists(): + return p + if (tui_dir / "dist" / "entry.js").exists(): + return tui_dir return None -def _make_tui_argv(tui_dir: Path) -> tuple[list[str], Path]: - """Gets argv to run tui + the working directory. Will npm install deps in dev mode.""" + +def _tui_build_needed(tui_dir: Path) -> bool: + entry = tui_dir / "dist" / "entry.js" + if not entry.exists(): + return True + dist_m = entry.stat().st_mtime + skip = frozenset({"node_modules", "dist"}) + for dirpath, dirnames, filenames in os.walk(tui_dir, topdown=True): + dirnames[:] = [d for d in dirnames if d not in skip] + for fn in filenames: + if fn.endswith((".ts", ".tsx")): + if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: + return True + for meta in ("package.json", "package-lock.json", "tsconfig.json", "tsconfig.build.json"): + mp = tui_dir / meta + if mp.exists() and mp.stat().st_mtime > dist_m: + return True + return False + + +def _hermes_ink_bundle_stale(tui_dir: Path) -> bool: + ink_root = tui_dir / "packages" / "hermes-ink" + bundle = ink_root / "dist" / "ink-bundle.js" + if not bundle.exists(): + return True + bm = bundle.stat().st_mtime + skip = frozenset({"node_modules", "dist"}) + for dirpath, dirnames, filenames in os.walk(ink_root, topdown=True): + dirnames[:] = [d for d in dirnames if d not in skip] + for fn in filenames: + if fn.endswith((".ts", ".tsx")): + if os.path.getmtime(os.path.join(dirpath, fn)) > bm: + return True + mp = ink_root / "package.json" + if mp.exists() and mp.stat().st_mtime > bm: + return True + return False + + +def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: + """Ink TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" def _node_bin(bin: str)-> str: path = shutil.which(bin) if not path: @@ -625,17 +665,8 @@ def _make_tui_argv(tui_dir: Path) -> tuple[list[str], Path]: sys.exit(1) return path - # use prebuilt TUI if it exists - bundled = _find_bundled_tui() - if bundled: - node = _node_bin("node") - return [node, str(bundled / "dist" / "entry.js")], bundled - - # dev mode - run via tsx - - # install deps if needed + npm = _node_bin("npm") if not (tui_dir / "node_modules").exists(): - npm = _node_bin("npm") print("Installing TUI dependencies…") result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], @@ -652,14 +683,54 @@ def _make_tui_argv(tui_dir: Path) -> tuple[list[str], Path]: print(preview) sys.exit(1) - tsx = tui_dir / "node_modules" / ".bin" / "tsx" - if tsx.exists(): - return [str(tsx), "src/entry.tsx"], tui_dir + if tui_dev: + if _hermes_ink_bundle_stale(tui_dir): + result = subprocess.run( + [npm, "run", "build", "--prefix", "packages/hermes-ink"], + cwd=str(tui_dir), + capture_output=True, + text=True, + ) + if result.returncode != 0: + combined = f"{result.stdout or ''}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print("@hermes/ink build failed.") + if preview: + print(preview) + sys.exit(1) + tsx = tui_dir / "node_modules" / ".bin" / "tsx" + if tsx.exists(): + return [str(tsx), "src/entry.tsx"], tui_dir + return [npm, "start"], tui_dir - npm = _node_bin("npm") - return [npm, "start"], tui_dir + env_bundle = os.environ.get("HERMES_TUI_DIR") + uses_packaged_dist = bool( + env_bundle and (Path(env_bundle) / "dist" / "entry.js").exists() + ) + if not uses_packaged_dist and _tui_build_needed(tui_dir): + result = subprocess.run( + [npm, "run", "build"], + cwd=str(tui_dir), + capture_output=True, + text=True, + ) + if result.returncode != 0: + combined = f"{result.stdout or ''}{result.stderr or ''}".strip() + preview = "\n".join(combined.splitlines()[-30:]) + print("TUI build failed.") + if preview: + print(preview) + sys.exit(1) -def _launch_tui(resume_session_id: Optional[str] = None): + root = _find_bundled_tui(tui_dir) + if not root: + print("TUI build did not produce dist/entry.js") + sys.exit(1) + + node = _node_bin("node") + return [node, str(root / "dist" / "entry.js")], root + +def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): """Replace current process with the Ink TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" @@ -668,7 +739,7 @@ def _launch_tui(resume_session_id: Optional[str] = None): if resume_session_id: env["HERMES_TUI_RESUME"] = resume_session_id - argv, cwd = _make_tui_argv(tui_dir) + argv, cwd = _make_tui_argv(tui_dir, tui_dev) try: code = subprocess.call(argv, cwd=str(cwd), env=env) except KeyboardInterrupt: @@ -719,7 +790,10 @@ def cmd_chat(args): # report "Session not found" with the original input if use_tui: - _launch_tui(getattr(args, "resume", None)) + _launch_tui( + getattr(args, "resume", None), + tui_dev=getattr(args, "tui_dev", False), + ) # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): @@ -4463,7 +4537,14 @@ For more help on a command: default=False, help="Launch the Ink-based terminal UI instead of the classic REPL" ) - + parser.add_argument( + "--dev", + dest="tui_dev", + action="store_true", + default=False, + help="With --tui: run TypeScript sources via tsx (skip dist build)", + ) + subparsers = parser.add_subparsers(dest="command", help="Command to run") # ========================================================================= @@ -4569,6 +4650,13 @@ For more help on a command: default=False, help="Launch the Ink-based terminal UI instead of the classic REPL" ) + chat_parser.add_argument( + "--dev", + dest="tui_dev", + action="store_true", + default=False, + help="With --tui: run TypeScript sources via tsx (skip dist build)", + ) chat_parser.set_defaults(func=cmd_chat) # ========================================================================= diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 1d4ff429af..96f7e145b4 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -25,7 +25,7 @@ def test_cmd_chat_tui_continue_uses_latest_tui_session(monkeypatch): calls.append(source) return "20260408_235959_a1b2c3" if source == "tui" else None - def fake_launch(resume_session_id=None): + def fake_launch(resume_session_id=None, tui_dev=False): captured["resume"] = resume_session_id raise SystemExit(0) @@ -54,7 +54,7 @@ def test_cmd_chat_tui_continue_falls_back_to_latest_cli_session(monkeypatch): return "20260408_235959_d4e5f6" return None - def fake_launch(resume_session_id=None): + def fake_launch(resume_session_id=None, tui_dev=False): captured["resume"] = resume_session_id raise SystemExit(0) @@ -74,7 +74,7 @@ def test_cmd_chat_tui_resume_resolves_title_before_launch(monkeypatch): captured = {} - def fake_launch(resume_session_id=None): + def fake_launch(resume_session_id=None, tui_dev=False): captured["resume"] = resume_session_id raise SystemExit(0) diff --git a/ui-tui/bun.lock b/ui-tui/bun.lock deleted file mode 100644 index c93554b990..0000000000 --- a/ui-tui/bun.lock +++ /dev/null @@ -1,756 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 0, - "workspaces": { - "": { - "name": "hermes-tui", - "dependencies": { - "ink": "^6.8.0", - "ink-text-input": "^6.0.0", - "react": "^19.2.4", - "unicode-animations": "^1.0.3", - }, - "devDependencies": { - "@eslint/js": "^9", - "@types/node": "^25.5.0", - "@types/react": "^19.2.14", - "@typescript-eslint/eslint-plugin": "^8", - "@typescript-eslint/parser": "^8", - "eslint": "^9", - "eslint-plugin-perfectionist": "^5", - "eslint-plugin-react": "^7", - "eslint-plugin-react-hooks": "^7", - "eslint-plugin-unused-imports": "^4", - "globals": "^16", - "prettier": "^3", - "tsx": "^4.19.0", - "typescript": "^5.7.0", - }, - }, - }, - "packages": { - "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], - - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], - - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-nGsF/4C7uzUj+Nj/4J+Zt0bYQ6bz33Phz8Lb2N80Mti1HjGclTJdXZ+9APC4kLvONbjxN1zfvYNd8FEcbBK/MQ=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.5", "", { "os": "android", "cpu": "arm" }, "sha512-Cv781jd0Rfj/paoNrul1/r4G0HLvuFKYh7C9uHZ2Pl8YXstzvCyyeWENTFR9qFnRzNMCjXmsulZuvosDg10Mog=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.5", "", { "os": "android", "cpu": "arm64" }, "sha512-Oeghq+XFgh1pUGd1YKs4DDoxzxkoUkvko+T/IVKwlghKLvvjbGFB3ek8VEDBmNvqhwuL0CQS3cExdzpmUyIrgA=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.5", "", { "os": "android", "cpu": "x64" }, "sha512-nQD7lspbzerlmtNOxYMFAGmhxgzn8Z7m9jgFkh6kpkjsAhZee1w8tJW3ZlW+N9iRePz0oPUDrYrXidCPSImD0Q=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I+Ya/MgC6rr8oRWGRDF3BXDfP8K1BVUggHqN6VI2lUZLdDi1IM1v2cy0e3lCPbP+pVcK3Tv8cgUhHse1kaNZZw=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-MCjQUtC8wWJn/pIPM7vQaO69BFgwPD1jriEdqwTCKzWjGgkMbcg+M5HzrOhPhuYe1AJjXlHmD142KQf+jnYj8A=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-X6xVS+goSH0UelYXnuf4GHLwpOdc8rgK/zai+dKzBMnncw7BTQIwquOodE7EKvY2UVUetSqyAfyZC1D+oqLQtg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-233X1FGo3a8x1ekLB6XT69LfZ83vqz+9z3TSEQCTYfMNY880A97nr81KbPcAMl9rmOFp11wO0dP+eB18KU/Ucg=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.5", "", { "os": "linux", "cpu": "arm" }, "sha512-0wkVrYHG4sdCCN/bcwQ7yYMXACkaHc3UFeaEOwSVW6e5RycMageYAFv+JS2bKLwHyeKVUvtoVH+5/RHq0fgeFw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-euKkilsNOv7x/M1NKsx5znyprbpsRFIzTV6lWziqJch7yWYayfLtZzDxDTl+LSQDJYAjd9TVb/Kt5UKIrj2e4A=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-hVRQX4+P3MS36NxOy24v/Cdsimy/5HYePw+tmPqnNN1fxV0bPrFWR6TMqwXPwoTM2VzbkA+4lbHWUKDd5ZDA/w=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-mKqqRuOPALI8nDzhOBmIS0INvZOOFGGg5n1osGIXAx8oersceEbKd4t1ACNTHM3sJBXGFAlEgqM+svzjPot+ZQ=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-EE/QXH9IyaAj1qeuIV5+/GZkBTipgGO782Ff7Um3vPS9cvLhJJeATy4Ggxikz2inZ46KByamMn6GqtqyVjhenA=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-0V2iF1RGxBf1b7/BjurA5jfkl7PtySjom1r6xOK2q9KWw/XCpAdtB6KNMO+9xx69yYfSCRR9FE0TyKfHA2eQMw=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.5", "", { "os": "linux", "cpu": "none" }, "sha512-rYxThBx6G9HN6tFNuvB/vykeLi4VDsm5hE5pVwzqbAjZEARQrWu3noZSfbEnPZ/CRXP3271GyFk/49up2W190g=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-uEP2q/4qgd8goEUc4QIdU/1P2NmEtZ/zX5u3OpLlCGhJIuBIv0s0wr7TB2nBrd3/A5XIdEkkS5ZLF0ULuvaaYQ=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.5", "", { "os": "linux", "cpu": "x64" }, "sha512-+Gq47Wqq6PLOOZuBzVSII2//9yyHNKZLuwfzCemqexqOQCSz0zy0O26kIzyp9EMNMK+nZ0tFHBZrCeVUuMs/ew=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-3F/5EG8VHfN/I+W5cO1/SV2H9Q/5r7vcHabMnBqhHK2lTWOh3F8vixNzo8lqxrlmBtZVFpW8pmITHnq54+Tq4g=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.5", "", { "os": "none", "cpu": "x64" }, "sha512-28t+Sj3CPN8vkMOlZotOmDgilQwVvxWZl7b8rxpn73Tt/gCnvrHxQUMng4uu3itdFvrtba/1nHejvxqz8xgEMA=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Doz/hKtiuVAi9hMsBMpwBANhIZc8l238U2Onko3t2xUp8xtM0ZKdDYHMnm/qPFVthY8KtxkXaocwmMh6VolzMA=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-WfGVaa1oz5A7+ZFPkERIbIhKT4olvGl1tyzTRaB5yoZRLqC0KwaO95FeZtOdQj/oKkjW57KcVF944m62/0GYtA=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.5", "", { "os": "none", "cpu": "arm64" }, "sha512-Xh+VRuh6OMh3uJ0JkCjI57l+DVe7VRGBYymen8rFPnTVgATBwA6nmToxM2OwTlSvrnWpPKkrQUj93+K9huYC6A=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-aC1gpJkkaUADHuAdQfuVTnqVUTLqqUNhAvEwHwVWcnVVZvNlDPGA0UveZsfXJJ9T6k9Po4eHi3c02gbdwO3g6w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-0UNx2aavV0fk6UpZcwXFLztA2r/k9jTUa7OW7SAea1VYUhkug99MW1uZeXEnPn5+cHOd0n8myQay6TlFnBR07w=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5nlJ3AeJWCTSzR7AEqVjT/faWyqKU86kCi1lLmxVqmNR+j4HrYdns+eTGjS/vmrzCIe8inGQckUadvS0+JkKdQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.5", "", { "os": "win32", "cpu": "x64" }, "sha512-PWypQR+d4FLfkhBIV+/kHsUELAnMpx1bRvvsn3p+/sAERbnCzFrtDRG2Xw5n+2zPxBK2+iaP+vetsRl4Ti7WgA=="], - - "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], - - "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], - - "@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="], - - "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], - - "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], - - "@eslint/eslintrc": ["@eslint/eslintrc@3.3.5", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg=="], - - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], - - "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], - - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], - - "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], - - "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], - - "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], - - "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - - "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], - - "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], - - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.58.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/type-utils": "8.58.0", "@typescript-eslint/utils": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.58.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg=="], - - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.58.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA=="], - - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.58.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.58.0", "@typescript-eslint/types": "^8.58.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg=="], - - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0" } }, "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ=="], - - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.58.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A=="], - - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0", "@typescript-eslint/utils": "8.58.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg=="], - - "@typescript-eslint/types": ["@typescript-eslint/types@8.58.0", "", {}, "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww=="], - - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.58.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.58.0", "@typescript-eslint/tsconfig-utils": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/visitor-keys": "8.58.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA=="], - - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.58.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.58.0", "@typescript-eslint/types": "8.58.0", "@typescript-eslint/typescript-estree": "8.58.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA=="], - - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.58.0", "", { "dependencies": { "@typescript-eslint/types": "8.58.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ=="], - - "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], - - "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], - - "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], - - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - - "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], - - "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], - - "array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="], - - "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], - - "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], - - "array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="], - - "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], - - "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], - - "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], - - "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.13", "", { "bin": "dist/cli.cjs" }, "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw=="], - - "brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], - - "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], - - "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], - - "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], - - "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], - - "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001784", "", {}, "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw=="], - - "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - - "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], - - "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], - - "cli-truncate": ["cli-truncate@5.2.0", "", { "dependencies": { "slice-ansi": "^8.0.0", "string-width": "^8.2.0" } }, "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw=="], - - "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - - "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], - - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], - - "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], - - "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], - - "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], - - "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], - - "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.331", "", {}, "sha512-IbxXrsTlD3hRodkLnbxAPP4OuJYdWCeM3IOdT+CpcMoIwIoDfCmRpEtSPfwBXxVkg9xmBeY7Lz2Eo2TDn/HC3Q=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], - - "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], - - "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], - - "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - - "es-iterator-helpers": ["es-iterator-helpers@1.3.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.1", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" } }, "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ=="], - - "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], - - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - - "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], - - "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], - - "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], - - "esbuild": ["esbuild@0.27.5", "", { "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" }, "bin": "bin/esbuild" }, "sha512-zdQoHBjuDqKsvV5OPaWansOwfSQ0Js+Uj9J85TBvj3bFW1JjWTSULMRwdQAc8qMeIScbClxeMK0jlrtB9linhA=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": "bin/eslint.js" }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], - - "eslint-plugin-perfectionist": ["eslint-plugin-perfectionist@5.8.0", "", { "dependencies": { "@typescript-eslint/utils": "^8.58.0", "natural-orderby": "^5.0.0" }, "peerDependencies": { "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" } }, "sha512-k8uIptWIxkUclonCFGyDzgYs9NI+Qh0a7cUXS3L7IYZDEsjXuimFBVbxXPQQngWqMiaxJRwbtYB4smMGMqF+cw=="], - - "eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="], - - "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], - - "eslint-plugin-unused-imports": ["eslint-plugin-unused-imports@4.4.1", "", { "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^10.0.0 || ^9.0.0 || ^8.0.0" } }, "sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ=="], - - "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], - - "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], - - "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], - - "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], - - "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - - "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], - - "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], - - "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], - - "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], - - "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="], - - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], - - "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], - - "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], - - "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], - - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], - - "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - - "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], - - "get-tsconfig": ["get-tsconfig@4.13.7", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q=="], - - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], - - "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], - - "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], - - "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], - - "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], - - "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], - - "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], - - "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], - - "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], - - "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], - - "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], - - "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], - - "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - - "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], - - "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], - - "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], - - "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], - - "ink": ["ink@6.8.0", "", { "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" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["react-devtools-core"] }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], - - "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], - - "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], - - "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], - - "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], - - "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], - - "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], - - "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - - "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], - - "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], - - "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], - - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - - "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], - - "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], - - "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], - - "is-in-ci": ["is-in-ci@2.0.0", "", { "bin": "cli.js" }, "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w=="], - - "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], - - "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], - - "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], - - "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], - - "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], - - "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], - - "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], - - "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], - - "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], - - "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], - - "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], - - "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], - - "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], - - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - - "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], - - "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], - - "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], - - "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], - - "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], - - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - - "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], - - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - - "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], - - "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - - "natural-orderby": ["natural-orderby@5.0.0", "", {}, "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg=="], - - "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], - - "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], - - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], - - "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - - "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], - - "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], - - "object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="], - - "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], - - "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], - - "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], - - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - - "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], - - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], - - "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - - "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], - - "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - - "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], - - "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - - "prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], - - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], - - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - - "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], - - "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - - "resolve": ["resolve@2.0.0-next.6", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA=="], - - "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], - - "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - - "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], - - "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], - - "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], - - "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], - - "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], - - "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], - - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], - - "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - - "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], - - "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], - - "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], - - "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], - - "slice-ansi": ["slice-ansi@8.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg=="], - - "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], - - "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - - "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], - - "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], - - "string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="], - - "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], - - "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], - - "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], - - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], - - "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], - - "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], - - "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], - - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - - "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], - - "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": "dist/cli.mjs" }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], - - "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], - - "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], - - "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], - - "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], - - "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], - - "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], - - "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], - - "unicode-animations": ["unicode-animations@1.0.3", "", { "dependencies": { "unicode-animations": "^1.0.1" }, "bin": { "unicode-animations": "scripts/demo.cjs" } }, "sha512-+klB2oWwcYZjYWhwP4Pr8UZffWDFVx6jKeIahE6z0QYyM2dwDeDPyn5nevCYbyotxvtT9lh21cVURO1RX0+YMg=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - - "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], - - "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], - - "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], - - "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], - - "widest-line": ["widest-line@6.0.0", "", { "dependencies": { "string-width": "^8.1.0" } }, "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA=="], - - "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - - "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], - - "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - - "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], - - "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - - "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], - - "@babel/core/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], - - "@eslint/config-array/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - - "@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "@eslint/eslintrc/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": "bin/semver.js" }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], - - "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], - - "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - - "eslint-plugin-react/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], - - "espree/eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], - - "ink/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "ink-text-input/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], - - "ink-text-input/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], - - "node-exports-info/semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], - - "widest-line/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], - - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], - - "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], - - "eslint-plugin-react/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], - - "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "@eslint/eslintrc/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], - - "eslint-plugin-react/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - } -} diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs index 7013dfdb6e..14a5d108d4 100644 --- a/ui-tui/eslint.config.mjs +++ b/ui-tui/eslint.config.mjs @@ -23,6 +23,9 @@ const customRules = { } export default [ + { + ignores: ['**/node_modules/**', '**/dist/**', 'src/**/*.js'] + }, js.configs.recommended, { files: ['**/*.{ts,tsx}'], @@ -89,6 +92,15 @@ export default [ } }, { - ignores: ['node_modules/', 'dist/', '*.config.*', 'src/**/*.js'] + files: ['**/*.js'], + ignores: ['**/node_modules/**', '**/dist/**'], + languageOptions: { + globals: { ...globals.node }, + ecmaVersion: 'latest', + sourceType: 'module' + } + }, + { + ignores: ['*.config.*'] } ] diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index ec79588fec..04c2767975 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -6641,6 +6641,9 @@ "usehooks-ts": "^3.1.0", "wrap-ansi": "^9.0.0" }, + "devDependencies": { + "esbuild": "^0.25.0" + }, "peerDependencies": { "ink-text-input": ">=6.0.0", "react": ">=19.0.0" @@ -6659,6 +6662,448 @@ "node": ">=14.13.1" } }, + "packages/hermes-ink/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "packages/hermes-ink/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "packages/hermes-ink/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", @@ -6683,6 +7128,48 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "packages/hermes-ink/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "packages/hermes-ink/node_modules/is-fullwidth-code-point": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", diff --git a/ui-tui/package.json b/ui-tui/package.json index 2fc6271f8c..e6e10ec06c 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -4,9 +4,10 @@ "private": true, "type": "module", "scripts": { - "dev": "tsx --watch src/entry.tsx", + "dev": "npm run build --prefix packages/hermes-ink && tsx --watch src/entry.tsx", "start": "tsx src/entry.tsx", - "build": "tsc -p tsconfig.build.json && chmod +x dist/entry.js", + "build": "npm run build --prefix packages/hermes-ink && tsc -p tsconfig.build.json && chmod +x dist/entry.js", + "type-check": "tsc --noEmit -p tsconfig.json", "lint": "eslint src/ packages/", "lint:fix": "eslint src/ packages/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'packages/**/*.{ts,tsx}'", diff --git a/ui-tui/packages/hermes-ink/ambient.d.ts b/ui-tui/packages/hermes-ink/ambient.d.ts new file mode 100644 index 0000000000..943ff76bc0 --- /dev/null +++ b/ui-tui/packages/hermes-ink/ambient.d.ts @@ -0,0 +1,83 @@ +/// + +declare module 'react/compiler-runtime' { + export function c(size: number): any[] +} + +declare module 'bidi-js' { + const bidiFactory: () => Record + export default bidiFactory +} + +declare module 'stack-utils' { + class StackUtils { + static nodeInternals(): RegExp[] + constructor(opts?: { cwd?: string; internals?: RegExp[] }) + clean(stack: string | undefined): string | undefined + parseLine(line: string): { file?: string; line?: number; column?: number; function?: string } | undefined + } + export default StackUtils +} + +declare module 'react-reconciler' { + export type FiberRoot = unknown + const createReconciler: any + export default createReconciler +} + +declare module 'react-reconciler/constants.js' { + export const ConcurrentRoot: number + export const LegacyRoot: number + export const DiscreteEventPriority: symbol | number + export const ContinuousEventPriority: symbol | number + export const DefaultEventPriority: symbol | number + export const NoEventPriority: symbol | number +} + +declare module 'lodash-es/noop.js' { + const noop: (...args: unknown[]) => void + export default noop +} + +declare module 'lodash-es/throttle.js' { + function throttle unknown>( + fn: T, + wait?: number, + opts?: { leading?: boolean; trailing?: boolean } + ): T & { cancel(): void; flush(): void } + export default throttle +} + +declare module 'semver' { + export function coerce(version: string | number | null | undefined): { version: string } | null + export function gt(a: string, b: string, opts?: { loose?: boolean }): boolean + export function gte(a: string, b: string, opts?: { loose?: boolean }): boolean + export function lt(a: string, b: string, opts?: { loose?: boolean }): boolean + export function lte(a: string, b: string, opts?: { loose?: boolean }): boolean + export function satisfies(version: string, range: string, opts?: { loose?: boolean }): boolean + export function compare(a: string, b: string, opts?: { loose?: boolean }): number +} + +interface BunSemver { + order(a: string, b: string): -1 | 0 | 1 + satisfies(version: string, range: string): boolean +} + +interface BunRuntime { + stringWidth(s: string, opts?: { ambiguousIsNarrow?: boolean }): number + semver: BunSemver + wrapAnsi?(input: string, columns: number, options?: { hard?: boolean; wordWrap?: boolean; trim?: boolean }): string +} + +declare var Bun: BunRuntime | undefined + +declare namespace React { + namespace JSX { + interface IntrinsicElements { + 'ink-box': Record + 'ink-text': Record + 'ink-link': Record + 'ink-raw-ansi': Record + } + } +} diff --git a/ui-tui/packages/hermes-ink/index.d.ts b/ui-tui/packages/hermes-ink/index.d.ts index 1c23959a35..6536bddb02 100644 --- a/ui-tui/packages/hermes-ink/index.d.ts +++ b/ui-tui/packages/hermes-ink/index.d.ts @@ -1,3 +1,4 @@ +/// export { default as useStderr } from './src/hooks/use-stderr.ts' export type { StderrHandle } from './src/hooks/use-stderr.ts' export { default as useStdout } from './src/hooks/use-stdout.ts' diff --git a/ui-tui/packages/hermes-ink/index.js b/ui-tui/packages/hermes-ink/index.js index be929ce6ca..758fef3073 100644 --- a/ui-tui/packages/hermes-ink/index.js +++ b/ui-tui/packages/hermes-ink/index.js @@ -1,25 +1 @@ -export { default as render, createRoot, renderSync } from './src/ink/root.ts' -export { default as Box } from './src/ink/components/Box.tsx' -export { default as Text } from './src/ink/components/Text.tsx' -export { Ansi } from './src/ink/Ansi.tsx' -export { AlternateScreen } from './src/ink/components/AlternateScreen.tsx' -export { default as Link } from './src/ink/components/Link.tsx' -export { default as Newline } from './src/ink/components/Newline.tsx' -export { NoSelect } from './src/ink/components/NoSelect.tsx' -export { RawAnsi } from './src/ink/components/RawAnsi.tsx' -export { default as ScrollBox } from './src/ink/components/ScrollBox.tsx' -export { default as Spacer } from './src/ink/components/Spacer.tsx' -export { default as measureElement } from './src/ink/measure-element.ts' -export { stringWidth } from './src/ink/stringWidth.ts' -export { default as useApp } from './src/ink/hooks/use-app.ts' -export { useDeclaredCursor } from './src/ink/hooks/use-declared-cursor.ts' -export { default as useInput } from './src/ink/hooks/use-input.ts' -export { default as useStdin } from './src/ink/hooks/use-stdin.ts' -export { useHasSelection, useSelection } from './src/ink/hooks/use-selection.ts' -export { default as useStdout } from './src/hooks/use-stdout.ts' -export { default as useStderr } from './src/hooks/use-stderr.ts' -export { useTabStatus } from './src/ink/hooks/use-tab-status.ts' -export { useTerminalFocus } from './src/ink/hooks/use-terminal-focus.ts' -export { useTerminalTitle } from './src/ink/hooks/use-terminal-title.ts' -export { useTerminalViewport } from './src/ink/hooks/use-terminal-viewport.ts' -export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' +export * from './dist/ink-bundle.js' diff --git a/ui-tui/packages/hermes-ink/package-lock.json b/ui-tui/packages/hermes-ink/package-lock.json new file mode 100644 index 0000000000..4fb5866d14 --- /dev/null +++ b/ui-tui/packages/hermes-ink/package-lock.json @@ -0,0 +1,819 @@ +{ + "name": "@hermes/ink", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@hermes/ink", + "version": "0.0.1", + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.1.0", + "auto-bind": "^5.0.0", + "bidi-js": "^1.0.0", + "chalk": "^5.4.0", + "cli-boxes": "^3.0.0", + "code-excerpt": "^4.0.0", + "emoji-regex": "^10.4.0", + "get-east-asian-width": "^1.3.0", + "indent-string": "^5.0.0", + "lodash-es": "^4.17.0", + "react": ">=19.0.0", + "react-reconciler": "0.33.0", + "semver": "^7.6.0", + "signal-exit": "^4.1.0", + "stack-utils": "^2.0.0", + "strip-ansi": "^7.1.0", + "supports-hyperlinks": "^3.1.0", + "type-fest": "^4.30.0", + "usehooks-ts": "^3.1.0", + "wrap-ansi": "^9.0.0" + }, + "devDependencies": { + "typescript": "~5.7.0" + }, + "peerDependencies": { + "ink-text-input": ">=6.0.0", + "react": ">=19.0.0" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.1.3", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=14.13.1" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "peer": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "peer": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.0.0.tgz", + "integrity": "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA==", + "license": "MIT", + "peer": true, + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "peer": true, + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.0.0.tgz", + "integrity": "sha512-fMie5/VwIYXofMyND0s+fOVhwVBBPYx+uuqJ6V6rUBGjui+2UYp+0fWtvhSeKT4z+X1uH98a4ge5Vj3aTlL6mg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/ink-text-input": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", + "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^5.3.0", + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "ink": ">=5", + "react": ">=18" + } + }, + "node_modules/ink/node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ink/node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ink/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "license": "(MIT OR CC0-1.0)", + "peer": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink/node_modules/wrap-ansi": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "peer": true, + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "peer": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "peer": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "peer": true + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-fest": { + "version": "4.41.0", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "peer": true, + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT", + "peer": true + } + } +} diff --git a/ui-tui/packages/hermes-ink/package.json b/ui-tui/packages/hermes-ink/package.json index 6741a24f93..8e23491310 100644 --- a/ui-tui/packages/hermes-ink/package.json +++ b/ui-tui/packages/hermes-ink/package.json @@ -3,19 +3,22 @@ "version": "0.0.1", "private": true, "type": "module", - "sideEffects": false, + "scripts": { + "build": "esbuild src/entry-exports.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/ink-bundle.js" + }, + "sideEffects": true, "main": "./index.js", "types": "./index.d.ts", "exports": { ".": { + "types": "./index.d.ts", "import": "./index.js", - "default": "./index.js", - "types": "./index.d.ts" + "default": "./index.js" }, "./text-input": { + "types": "./text-input.d.ts", "import": "./text-input.js", - "default": "./text-input.js", - "types": "./text-input.d.ts" + "default": "./text-input.js" }, "./package.json": "./package.json" }, @@ -44,5 +47,8 @@ "type-fest": "^4.30.0", "usehooks-ts": "^3.1.0", "wrap-ansi": "^9.0.0" + }, + "devDependencies": { + "esbuild": "^0.25.0" } } diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts new file mode 100644 index 0000000000..d9fd98deed --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -0,0 +1,25 @@ +export { default as useStderr } from './hooks/use-stderr.js' +export { default as useStdout } from './hooks/use-stdout.js' +export { Ansi } from './ink/Ansi.js' +export { AlternateScreen } from './ink/components/AlternateScreen.js' +export { default as Box } from './ink/components/Box.js' +export { default as Link } from './ink/components/Link.js' +export { default as Newline } from './ink/components/Newline.js' +export { NoSelect } from './ink/components/NoSelect.js' +export { RawAnsi } from './ink/components/RawAnsi.js' +export { default as ScrollBox } from './ink/components/ScrollBox.js' +export { default as Spacer } from './ink/components/Spacer.js' +export { default as Text } from './ink/components/Text.js' +export { default as useApp } from './ink/hooks/use-app.js' +export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js' +export { default as useInput } from './ink/hooks/use-input.js' +export { useHasSelection, useSelection } from './ink/hooks/use-selection.js' +export { default as useStdin } from './ink/hooks/use-stdin.js' +export { useTabStatus } from './ink/hooks/use-tab-status.js' +export { useTerminalFocus } from './ink/hooks/use-terminal-focus.js' +export { useTerminalTitle } from './ink/hooks/use-terminal-title.js' +export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' +export { default as measureElement } from './ink/measure-element.js' +export { createRoot, default as render, renderSync } from './ink/root.js' +export { stringWidth } from './ink/stringWidth.js' +export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/Ansi.tsx b/ui-tui/packages/hermes-ink/src/ink/Ansi.tsx index e37eca558f..de0d750c35 100644 --- a/ui-tui/packages/hermes-ink/src/ink/Ansi.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/Ansi.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { type ReactNode } from 'react' import { c as _c } from 'react/compiler-runtime' import Link from './components/Link.js' @@ -6,7 +6,7 @@ import Text from './components/Text.js' import type { Color } from './styles.js' import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js' type Props = { - children: string + children?: ReactNode /** When true, force all text to be rendered with dim styling */ dimColor?: boolean } @@ -22,6 +22,11 @@ type SpanProps = { hyperlink?: string } +type Span = { + text: string + props: SpanProps +} + /** * Component that parses ANSI escape codes and renders them using Text components. * @@ -30,7 +35,7 @@ type SpanProps = { * * Memoized to prevent re-renders when parent changes but children string is the same. */ -export const Ansi = React.memo(function Ansi(t0) { +export const Ansi = React.memo(function Ansi(t0: Props) { const $ = _c(12) const { children, dimColor } = t0 @@ -78,7 +83,7 @@ export const Ansi = React.memo(function Ansi(t0) { let t3 if ($[7] !== dimColor) { - t3 = (span, i) => { + t3 = (span: Span, i: number) => { const hyperlink = span.props.hyperlink if (dimColor) { @@ -165,10 +170,6 @@ export const Ansi = React.memo(function Ansi(t0) { return t3 }) -type Span = { - text: string - props: SpanProps -} /** * Parse an ANSI string into spans using the termio parser. @@ -359,7 +360,7 @@ type BaseTextStyleProps = { } // Wrapper component that handles bold/dim mutual exclusivity for Text -function StyledText(t0) { +function StyledText(t0: BaseTextStyleProps & { bold?: boolean; dim?: boolean; children?: ReactNode }) { const $ = _c(14) let bold let children diff --git a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx index 757f7789b8..bb18608172 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/AlternateScreen.tsx @@ -32,7 +32,7 @@ type Props = PropsWithChildren<{ * from scrolling content) and so signal-exit cleanup can exit the alt * screen if the component's own unmount doesn't run. */ -export function AlternateScreen(t0) { +export function AlternateScreen(t0: Props) { const $ = _c(7) const { children, mouseTracking: t1 } = t0 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx index 13ec469954..68ba67ea54 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx @@ -1,6 +1,6 @@ import '../global.d.ts' -import React, { type Ref } from 'react' +import React, { type ReactNode, type Ref } from 'react' import { c as _c } from 'react/compiler-runtime' import type { Except } from 'type-fest' @@ -11,6 +11,7 @@ import type { KeyboardEvent } from '../events/keyboard-event.js' import type { Styles } from '../styles.js' import * as warn from '../warn.js' export type Props = Except & { + children?: ReactNode ref?: Ref /** * Tab order index. Nodes with `tabIndex >= 0` participate in @@ -50,7 +51,7 @@ export type Props = Except & { /** * `` is an essential Ink component to build your layout. It's like `
` in the browser. */ -function Box(t0) { +function Box(t0: Props) { const $ = _c(42) let autoFocus let children diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx index 521cd57513..99dfc2d883 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ClockContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useEffect, useState } from 'react' +import React, { createContext, type ReactNode, useEffect, useState } from 'react' import { c as _c } from 'react/compiler-runtime' import { BLURRED_FRAME_INTERVAL_MS, FRAME_INTERVAL_MS } from '../constants.js' @@ -87,7 +87,7 @@ export const ClockContext = createContext(null) // Own component so App.tsx doesn't re-render when the clock is created. // The clock value is stable (created once via useState), so the provider // never causes consumer re-renders on its own. -export function ClockProvider(t0) { +export function ClockProvider(t0: { readonly children: ReactNode }) { const $ = _c(7) const { children } = t0 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx index 72c94fa11f..71c4914558 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Link.tsx @@ -11,7 +11,7 @@ export type Props = { readonly fallback?: ReactNode } -export default function Link(t0) { +export default function Link(t0: Props) { const $ = _c(5) const { children, url, fallback } = t0 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx index 54dfa50fa6..4010dc9ffd 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Newline.tsx @@ -12,7 +12,7 @@ export type Props = { /** * Adds one or more newline (\n) characters. Must be used within components. */ -export default function Newline(t0) { +export default function Newline(t0: Props) { const $ = _c(4) const { count: t1 } = t0 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx b/ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx index e3da698520..79078189e4 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/NoSelect.tsx @@ -33,7 +33,7 @@ type Props = Omit & { * tracking). No-op in the main-screen scrollback render where the * terminal's native selection is used instead. */ -export function NoSelect(t0) { +export function NoSelect(t0: Props) { const $ = _c(8) let boxProps let children diff --git a/ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx b/ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx index 2c0b2f0fee..b5bd8f2536 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/RawAnsi.tsx @@ -25,7 +25,7 @@ type Props = { * (width × lines.length) and hands the joined string straight to output.write(), * which already splits on '\n' and parses ANSI into the screen buffer. */ -export function RawAnsi(t0) { +export function RawAnsi(t0: Props) { const $ = _c(6) const { lines, width } = t0 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index e7b55e71d6..bed421234f 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -252,7 +252,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren< // commit, which is too late for the first frame. return ( { + ref={(el: DOMElement | null) => { domRef.current = el if (el) { diff --git a/ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx b/ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx index 02860485a7..e5f1acdd68 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/TerminalFocusContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useSyncExternalStore } from 'react' +import React, { createContext, type ReactNode, useSyncExternalStore } from 'react' import { c as _c } from 'react/compiler-runtime' import { @@ -23,7 +23,7 @@ TerminalFocusContext.displayName = 'TerminalFocusContext' // Separate component so App.tsx doesn't re-render on focus changes. // Children are a stable prop reference, so they don't re-render either — // only components that consume the context will re-render. -export function TerminalFocusProvider(t0) { +export function TerminalFocusProvider(t0: { readonly children: ReactNode }) { const $ = _c(6) const { children } = t0 diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx index f69d338c1f..ea2a74c9a6 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Text.tsx @@ -116,7 +116,7 @@ const memoizedStylesForWrap: Record, Styles> = { /** * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough. */ -export default function Text(t0) { +export default function Text(t0: Props) { const $ = _c(29) const { diff --git a/ui-tui/packages/hermes-ink/src/ink/devtools.ts b/ui-tui/packages/hermes-ink/src/ink/devtools.ts new file mode 100644 index 0000000000..73b0c9448d --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/devtools.ts @@ -0,0 +1,2 @@ +/** Optional react-devtools hook; package may be absent. */ +export {} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/paste-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/paste-event.ts new file mode 100644 index 0000000000..38a88f3171 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/paste-event.ts @@ -0,0 +1,10 @@ +import { TerminalEvent } from './terminal-event.js' + +export class PasteEvent extends TerminalEvent { + readonly text: string + + constructor(text: string) { + super('paste', { bubbles: true, cancelable: true }) + this.text = text + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/events/resize-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/resize-event.ts new file mode 100644 index 0000000000..b2627bb290 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/resize-event.ts @@ -0,0 +1,12 @@ +import { TerminalEvent } from './terminal-event.js' + +export class ResizeEvent extends TerminalEvent { + readonly columns: number + readonly rows: number + + constructor(columns: number, rows: number) { + super('resize', { bubbles: true, cancelable: true }) + this.columns = columns + this.rows = rows + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index e0163f5065..96898cee31 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -339,8 +339,6 @@ export default class Ink { } } - // @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks, - // but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks) this.container = reconciler.createContainer( this.rootNode, ConcurrentRoot, @@ -357,7 +355,7 @@ export default class Ink { noop // onDefaultTransitionIndicator ) - if ('production' === 'development') { + if (process.env.NODE_ENV === 'development') { reconciler.injectIntoDevTools({ bundleType: 0, // Reporting React DOM's version, not Ink's @@ -955,7 +953,6 @@ export default class Ink { } pause(): void { // Flush pending React updates and render before pausing. - // @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler reconciler.flushSyncFromReconciler() this.onRender() this.isPaused = true @@ -1783,9 +1780,7 @@ export default class Ink { ) - // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler reconciler.updateContainerSync(tree, this.container, null, noop) - // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler reconciler.flushSyncWork() } unmount(error?: Error | number | null): void { @@ -1857,9 +1852,7 @@ export default class Ink { this.drainTimer = null } - // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler reconciler.updateContainerSync(null, this.container, null, noop) - // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler reconciler.flushSyncWork() instances.delete(this.options.stdout) @@ -1966,8 +1959,8 @@ export default class Ink { const intercept = ( chunk: Uint8Array | string, - encodingOrCb?: BufferEncoding | ((err?: Error) => void), - cb?: (err?: Error) => void + encodingOrCb?: BufferEncoding | ((err?: Error | null) => void), + cb?: (err?: Error | null) => void ): boolean => { const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb diff --git a/ui-tui/packages/hermes-ink/src/ink/reconciler.ts b/ui-tui/packages/hermes-ink/src/ink/reconciler.ts index 2be8a7d7ca..5fdce3bf9c 100644 --- a/ui-tui/packages/hermes-ink/src/ink/reconciler.ts +++ b/ui-tui/packages/hermes-ink/src/ink/reconciler.ts @@ -176,27 +176,12 @@ export function resetProfileCounters(): void { } // --- END --- -const reconciler = createReconciler< - ElementNames, - Props, - DOMElement, - DOMElement, - TextNode, - DOMElement, - unknown, - unknown, - DOMElement, - HostContext, - null, // UpdatePayload - not used in React 19 - NodeJS.Timeout, - -1, - null ->({ +const reconciler = createReconciler({ getRootHostContext: () => ({ isInsideText: false }), prepareForCommit: () => null, preparePortalMount: () => null, clearContainer: () => false, - resetAfterCommit(rootNode) { + resetAfterCommit(rootNode: DOMElement) { _lastCommitMs = _commitStart > 0 ? performance.now() - _commitStart : 0 _commitStart = 0 @@ -261,19 +246,19 @@ const reconciler = createReconciler< return createTextNode(text) }, resetTextContent() {}, - hideTextInstance(node) { + hideTextInstance(node: TextNode) { setTextNodeValue(node, '') }, - unhideTextInstance(node, text) { + unhideTextInstance(node: TextNode, text: string) { setTextNodeValue(node, text) }, - getPublicInstance: (instance): DOMElement => instance as DOMElement, - hideInstance(node) { + getPublicInstance: (instance: DOMElement): DOMElement => instance, + hideInstance(node: DOMElement) { node.isHidden = true node.yogaNode?.setDisplay(LayoutDisplay.None) markDirty(node) }, - unhideInstance(node) { + unhideInstance(node: DOMElement) { node.isHidden = false node.yogaNode?.setDisplay(LayoutDisplay.Flex) markDirty(node) @@ -344,7 +329,7 @@ const reconciler = createReconciler< commitTextUpdate(node: TextNode, _oldText: string, newText: string): void { setTextNodeValue(node, newText) }, - removeChild(node, removeNode) { + removeChild(node: DOMElement, removeNode: DOMElement | TextNode) { removeChildNode(node, removeNode) cleanupYogaNode(removeNode) diff --git a/ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts b/ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts index bee9f8f1c5..57272bd36a 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-to-screen.ts @@ -63,14 +63,11 @@ export function renderToScreen(el: ReactElement, width: number): { screen: Scree stylePool = new StylePool() charPool = new CharPool() hyperlinkPool = new HyperlinkPool() - // @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11 container = reconciler.createContainer(root, LegacyRoot, null, false, null, 'search-render', noop, noop, noop, noop) } const t0 = performance.now() - // @ts-expect-error updateContainerSync exists but not in @types reconciler.updateContainerSync(el, container, null, noop) - // @ts-expect-error flushSyncWork exists but not in @types reconciler.flushSyncWork() const t1 = performance.now() @@ -105,9 +102,7 @@ export function renderToScreen(el: ReactElement, width: number): { screen: Scree const t3 = performance.now() // Unmount so next call gets a fresh tree. Leaves root/container/pools. - // @ts-expect-error updateContainerSync exists but not in @types reconciler.updateContainerSync(null, container, null, noop) - // @ts-expect-error flushSyncWork exists but not in @types reconciler.flushSyncWork() timing.reconcile += t1 - t0 diff --git a/ui-tui/packages/hermes-ink/src/utils/semver.ts b/ui-tui/packages/hermes-ink/src/utils/semver.ts index ab57ecf720..87025ed0fd 100644 --- a/ui-tui/packages/hermes-ink/src/utils/semver.ts +++ b/ui-tui/packages/hermes-ink/src/utils/semver.ts @@ -53,5 +53,5 @@ export function order(a: string, b: string): -1 | 0 | 1 { return Bun.semver.order(a, b) } - return getNpmSemver().compare(a, b, { loose: true }) + return getNpmSemver().compare(a, b, { loose: true }) as -1 | 0 | 1 } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index c32692210a..35f1448a15 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1590,12 +1590,17 @@ export function App({ gw }: { gw: GatewayClient }) { if (!pastes.length) { sys('no text pastes') } else { - panel('Paste Shelf', [{ - rows: pastes.map(p => [ - `#${p.id} ${p.mode}`, - `${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` - ] as [string, string]) - }]) + panel('Paste Shelf', [ + { + rows: pastes.map( + p => + [ + `#${p.id} ${p.mode}`, + `${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` + ] as [string, string] + ) + } + ]) } return true @@ -1648,7 +1653,6 @@ export function App({ gw }: { gw: GatewayClient }) { sys('usage: /paste [list|mode |drop |clear]') return true - case 'logs': { const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) logText ? page(logText, 'Logs') : sys('no gateway logs') @@ -1761,7 +1765,14 @@ export function App({ gw }: { gw: GatewayClient }) { case 'model': if (!arg) { rpc('config.get', { key: 'provider' }).then((r: any) => - panel('Model', [{ rows: [['Model', r.model], ['Provider', r.provider]] }]) + panel('Model', [ + { + rows: [ + ['Model', r.model], + ['Provider', r.provider] + ] + } + ]) ) } else { rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( @@ -1893,6 +1904,7 @@ export function App({ gw }: { gw: GatewayClient }) { } const f = (v: number) => (v ?? 0).toLocaleString() + const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null @@ -1906,7 +1918,9 @@ export function App({ gw }: { gw: GatewayClient }) { ['API calls', f(r.calls)] ] - if (cost) rows.push(['Cost', cost]) + if (cost) { + rows.push(['Cost', cost]) + } const sections: PanelSection[] = [{ rows }] @@ -1914,7 +1928,9 @@ export function App({ gw }: { gw: GatewayClient }) { sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) } - if (r.compressions) sections.push({ text: `Compressions: ${r.compressions}` }) + if (r.compressions) { + sections.push({ text: `Compressions: ${r.compressions}` }) + } panel('Usage', sections) }) @@ -1959,13 +1975,15 @@ export function App({ gw }: { gw: GatewayClient }) { case 'insights': rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => - panel('Insights', [{ - rows: [ - ['Period', `${r.days} days`], - ['Sessions', `${r.sessions}`], - ['Messages', `${r.messages}`] - ] - }]) + panel('Insights', [ + { + rows: [ + ['Period', `${r.days} days`], + ['Sessions', `${r.sessions}`], + ['Messages', `${r.messages}`] + ] + } + ]) ) return true @@ -1978,12 +1996,13 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no checkpoints') } - panel('Checkpoints', [{ - rows: r.checkpoints.map((c: any, i: number) => [ - `${i + 1} ${c.hash?.slice(0, 8)}`, - c.message - ] as [string, string]) - }]) + panel('Checkpoints', [ + { + rows: r.checkpoints.map( + (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] + ) + } + ]) }) } else { const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub @@ -2016,9 +2035,11 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no plugins') } - panel('Plugins', [{ - items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) - }]) + panel('Plugins', [ + { + items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) + } + ]) }) return true @@ -2033,10 +2054,13 @@ export function App({ gw }: { gw: GatewayClient }) { return sys('no skills installed') } - panel('Installed Skills', Object.entries(sk).map(([cat, names]) => ({ - title: cat, - items: names as string[] - }))) + panel( + 'Installed Skills', + Object.entries(sk).map(([cat, names]) => ({ + title: cat, + items: names as string[] + })) + ) }) return true @@ -2045,17 +2069,29 @@ export function App({ gw }: { gw: GatewayClient }) { if (sub === 'browse') { const pg = parseInt(sArgs[0] ?? '1', 10) || 1 rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { - if (!r.items?.length) return sys('no skills found in the hub') + if (!r.items?.length) { + return sys('no skills found in the hub') + } - const sections: PanelSection[] = [{ - rows: r.items.map((s: any) => [ - s.name ?? '', - (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '') - ] as [string, string]) - }] + const sections: PanelSection[] = [ + { + rows: r.items.map( + (s: any) => + [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [ + string, + string + ] + ) + } + ] - if (r.page < r.total_pages) sections.push({ text: `/skills browse ${r.page + 1} → next page` }) - if (r.page > 1) sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) + if (r.page < r.total_pages) { + sections.push({ text: `/skills browse ${r.page + 1} → next page` }) + } + + if (r.page > 1) { + sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) + } panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) }) @@ -2073,47 +2109,57 @@ export function App({ gw }: { gw: GatewayClient }) { case 'agents': case 'tasks': - rpc('agents.list', {}).then((r: any) => { - const procs = r.processes ?? [] - const running = procs.filter((p: any) => p.status === 'running') - const finished = procs.filter((p: any) => p.status !== 'running') - const sections: PanelSection[] = [] + rpc('agents.list', {}) + .then((r: any) => { + const procs = r.processes ?? [] + const running = procs.filter((p: any) => p.status === 'running') + const finished = procs.filter((p: any) => p.status !== 'running') + const sections: PanelSection[] = [] - if (running.length) { - sections.push({ - title: `Running (${running.length})`, - rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } + if (running.length) { + sections.push({ + title: `Running (${running.length})`, + rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } - if (finished.length) { - sections.push({ - title: `Finished (${finished.length})`, - rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } + if (finished.length) { + sections.push({ + title: `Finished (${finished.length})`, + rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } - if (!sections.length) sections.push({ text: 'No active processes' }) + if (!sections.length) { + sections.push({ text: 'No active processes' }) + } - panel('Agents', sections) - }).catch(() => sys('agents command failed')) + panel('Agents', sections) + }) + .catch(() => sys('agents command failed')) return true case 'cron': if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }).then((r: any) => { - const jobs = r.jobs ?? [] + rpc('cron.manage', { action: 'list' }) + .then((r: any) => { + const jobs = r.jobs ?? [] - if (!jobs.length) return sys('no scheduled jobs') + if (!jobs.length) { + return sys('no scheduled jobs') + } - panel('Cron', [{ - rows: jobs.map((j: any) => [ - j.name || j.job_id?.slice(0, 12), - `${j.schedule} · ${j.state ?? 'active'}` - ] as [string, string]) - }]) - }).catch(() => sys('cron command failed')) + panel('Cron', [ + { + rows: jobs.map( + (j: any) => + [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string] + ) + } + ]) + }) + .catch(() => sys('cron command failed')) } else { gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || '(no output)')) @@ -2123,38 +2169,59 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'config': - rpc('config.show', {}).then((r: any) => { - panel('Config', (r.sections ?? []).map((s: any) => ({ - title: s.title, - rows: s.rows - }))) - }).catch(() => sys('config command failed')) + rpc('config.show', {}) + .then((r: any) => { + panel( + 'Config', + (r.sections ?? []).map((s: any) => ({ + title: s.title, + rows: s.rows + })) + ) + }) + .catch(() => sys('config command failed')) return true case 'tools': - rpc('tools.list', { session_id: sid }).then((r: any) => { - if (!r.toolsets?.length) return sys('no tools') + rpc('tools.list', { session_id: sid }) + .then((r: any) => { + if (!r.toolsets?.length) { + return sys('no tools') + } - panel('Tools', r.toolsets.map((ts: any) => ({ - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, - items: ts.tools - }))) - }).catch(() => sys('tools command failed')) + panel( + 'Tools', + r.toolsets.map((ts: any) => ({ + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, + items: ts.tools + })) + ) + }) + .catch(() => sys('tools command failed')) return true case 'toolsets': - rpc('toolsets.list', { session_id: sid }).then((r: any) => { - if (!r.toolsets?.length) return sys('no toolsets') + rpc('toolsets.list', { session_id: sid }) + .then((r: any) => { + if (!r.toolsets?.length) { + return sys('no toolsets') + } - panel('Toolsets', [{ - rows: r.toolsets.map((ts: any) => [ - `${ts.enabled ? '(*)' : ' '} ${ts.name}`, - `[${ts.tool_count}] ${ts.description}` - ] as [string, string]) - }]) - }).catch(() => sys('toolsets command failed')) + panel('Toolsets', [ + { + rows: r.toolsets.map( + (ts: any) => + [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ + string, + string + ] + ) + } + ]) + }) + .catch(() => sys('toolsets command failed')) return true @@ -2181,7 +2248,23 @@ export function App({ gw }: { gw: GatewayClient }) { return true } }, - [catalog, compact, gw, lastUserMsg, messages, newSession, page, panel, pastes, pushActivity, rpc, send, sid, statusBar, sys] + [ + catalog, + compact, + gw, + lastUserMsg, + messages, + newSession, + page, + panel, + pastes, + pushActivity, + rpc, + send, + sid, + statusBar, + sys + ] ) slashRef.current = slash diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 429996db70..d37f86f712 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -179,4 +179,3 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The ) } - diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index b41a0b27e5..ff4f08b00b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1,4 +1,5 @@ import * as Ink from '@hermes/ink' +import type { InputEvent, Key } from '@hermes/ink' import { useEffect, useMemo, useRef, useState } from 'react' type InkExt = typeof Ink & { @@ -303,7 +304,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl // ── Input handler ──────────────────────────────────────────────── useInput( - (inp, k, event) => { + (inp: string, k: Key, event: InputEvent) => { // Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16). const ctrlPaste = k.ctrl && (inp.toLowerCase() === 'v' || event.keypress.raw === '\x16') const metaPaste = k.meta && inp.toLowerCase() === 'v' diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index db77c9f2a0..7c8a8a7246 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -22,7 +22,13 @@ declare module '@hermes/ink' { readonly [key: string]: boolean } - export type InputHandler = (input: string, key: Key) => void + export type InputEvent = { + readonly input: string + readonly key: Key + readonly keypress: { readonly raw?: string } + } + + export type InputHandler = (input: string, key: Key, event: InputEvent) => void export type RenderOptions = { readonly stdin?: NodeJS.ReadStream From 24a498eb9027983aa93afb3e3b671e3d897f9311 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 17:15:36 -0500 Subject: [PATCH 074/157] feat: better markdown --- ui-tui/src/__tests__/text.test.ts | 16 +- ui-tui/src/components/markdown.tsx | 432 ++++++++++++++++++++++++----- ui-tui/src/lib/text.ts | 33 ++- 3 files changed, 407 insertions(+), 74 deletions(-) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 55b6a272b3..d43f6d56f4 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js' +import { estimateRows, fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js' describe('isToolTrailResultLine', () => { it('detects completion markers', () => { @@ -49,3 +49,17 @@ describe('fmtK', () => { expect(fmtK(1_000_000_000)).toBe('1B') }) }) + +describe('estimateRows', () => { + it('handles tilde code fences', () => { + const md = ['~~~markdown', '# heading', '~~~'].join('\n') + + expect(estimateRows(md, 40)).toBeGreaterThanOrEqual(2) + }) + + it('handles checklist bullets as list rows', () => { + const md = ['- [x] done', '- [ ] todo'].join('\n') + + expect(estimateRows(md, 40)).toBe(2) + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 64403c2977..8d5cf888fe 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -3,17 +3,104 @@ import type { ReactNode } from 'react' import type { Theme } from '../theme.js' -/** OSC 8 hyperlink — wrap-ansi / Ink keep the link active across soft line wraps. */ -const osc8 = (url: string) => '\x1b]8;;' + url + '\x1b\\' -const OSC8_END = '\x1b]8;;\x1b\\' +const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/ +const HR_RE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/ +const HEADING_RE = /^\s{0,3}(#{1,6})\s+(.*?)(?:\s+#+\s*)?$/ +const FOOTNOTE_RE = /^\[\^([^\]]+)\]:\s*(.*)$/ +const DEF_RE = /^\s*:\s+(.+)$/ +const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/ +const MD_URL_RE = '((?:[^\\s()]|\\([^\\s()]*\\))+?)' +const INLINE_RE = + new RegExp( + `(!\\[(.*?)\\]\\(${MD_URL_RE}\\)|\\[(.+?)\\]\\(${MD_URL_RE}\\)|<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|__(.+?)__|\\*(.+?)\\*|_(.+?)_|==(.+?)==|\\[\\^([^\\]]+)\\]|\\^([^^\\s][^^]*?)\\^|~([^~\\s][^~]*?)~|(https?:\\/\\/[^\\s<]+))`, + 'g' + ) + +type Fence = { + char: '`' | '~' + lang: string + len: number +} + +const renderLink = (key: number, t: Theme, label: string) => ( + + {label} + +) + +const trimBareUrl = (value: string) => { + const trimmed = value.replace(/[),.;:!?]+$/g, '') + + return { + tail: value.slice(trimmed.length), + url: trimmed + } +} + +const renderAutolink = (key: number, t: Theme, raw: string) => ( + + {raw.replace(/^mailto:/, '')} + +) + +const indentDepth = (indent: string) => Math.floor(indent.replace(/\t/g, ' ').length / 2) + +const parseFence = (line: string): Fence | null => { + const m = line.match(FENCE_RE) + + if (!m) { + return null + } + + return { + char: m[1]![0] as '`' | '~', + lang: m[2]!.trim().toLowerCase(), + len: m[1]!.length + } +} + +const isFenceClose = (line: string, fence: Fence) => { + const end = line.match(/^\s*(`{3,}|~{3,})\s*$/) + + return Boolean(end && end[1]![0] === fence.char && end[1]!.length >= fence.len) +} + +const isMarkdownFence = (lang: string) => ['md', 'markdown'].includes(lang) + +const splitTableRow = (row: string) => + row + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map(cell => cell.trim()) + +const isTableDivider = (row: string) => { + const cells = splitTableRow(row) + + return cells.length > 1 && cells.every(cell => TABLE_DIVIDER_CELL_RE.test(cell)) +} + +const renderTable = (key: number, rows: string[][], t: Theme) => { + const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => (r[ci] ?? '').length))) + + return ( + + {rows.map((row, ri) => ( + + {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} + + ))} + + ) +} function MdInline({ t, text }: { t: Theme; text: string }) { const parts: ReactNode[] = [] - const re = /(\[(.+?)\]\((https?:\/\/[^\s)]+)\)|\*\*(.+?)\*\*|`([^`]+)`|\*(.+?)\*|(https?:\/\/[^\s]+))/g let last = 0 - for (const m of text.matchAll(re)) { + for (const m of text.matchAll(INLINE_RE)) { const i = m.index ?? 0 if (i > last) { @@ -22,43 +109,74 @@ function MdInline({ t, text }: { t: Theme; text: string }) { if (m[2] && m[3]) { parts.push( - - {osc8(m[3])} - - {m[2]} - - {OSC8_END} + + [image: {m[2]}] {m[3]} ) - } else if (m[4]) { + } else if (m[4] && m[5]) { + parts.push(renderLink(parts.length, t, m[4])) + } else if (m[6]) { + parts.push(renderAutolink(parts.length, t, m[6])) + } else if (m[7]) { parts.push( - - {m[4]} + + {m[7]} ) - } else if (m[5]) { + } else if (m[8]) { parts.push( - {m[5]} + {m[8]} ) - } else if (m[6]) { + } else if (m[9] || m[10]) { + parts.push( + + {m[9] ?? m[10]} + + ) + } else if (m[11] || m[12]) { parts.push( - {m[6]} + {m[11] ?? m[12]} ) - } else if (m[7]) { - const u = m[7] + } else if (m[13]) { parts.push( - - {osc8(u)} - - {u} - - {OSC8_END} + + {m[13]} ) + } else if (m[14]) { + parts.push( + + [{m[14]}] + + ) + } else if (m[15]) { + parts.push( + + ^{m[15]} + + ) + } else if (m[16]) { + parts.push( + + _{m[16]} + + ) + } else if (m[17]) { + const { tail, url } = trimBareUrl(m[17]) + + parts.push(renderAutolink(parts.length, t, url)) + + if (tail) { + parts.push( + + {tail} + + ) + } } last = i + m[0].length @@ -75,7 +193,16 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const lines = text.split('\n') const nodes: ReactNode[] = [] let i = 0 - let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'table' | null = null + let prevKind: + | 'blank' + | 'code' + | 'heading' + | 'list' + | 'paragraph' + | 'quote' + | 'rule' + | 'table' + | null = null const gap = () => { if (nodes.length && prevKind !== 'blank') { @@ -109,16 +236,29 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - if (line.startsWith('```')) { - start('code') - const lang = line.slice(3).trim() - const block: string[] = [] + const fence = parseFence(line) - for (i++; i < lines.length && !lines[i]!.startsWith('```'); i++) { + if (fence) { + const block: string[] = [] + const lang = fence.lang + + for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) { block.push(lines[i]!) } - i++ + if (i < lines.length) { + i++ + } + + if (isMarkdownFence(lang)) { + start('paragraph') + nodes.push() + + continue + } + + start('code') + const isDiff = lang === 'diff' nodes.push( @@ -146,13 +286,42 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - const heading = line.match(/^#{1,3}\s+(.*)/) + if (line.trim().startsWith('$$')) { + start('code') + + const block: string[] = [] + + for (i++; i < lines.length; i++) { + if (lines[i]!.trim().startsWith('$$')) { + i++ + + break + } + + block.push(lines[i]!) + } + + nodes.push( + + ─ math + {block.map((l, j) => ( + + {l} + + ))} + + ) + + continue + } + + const heading = line.match(HEADING_RE) if (heading) { start('heading') nodes.push( - {heading[1]} + {heading[2]} ) i++ @@ -160,14 +329,103 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - const bullet = line.match(/^\s*[-*]\s(.*)/) + if (i + 1 < lines.length && line.trim()) { + const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/) + + if (setext) { + start('heading') + nodes.push( + + {line.trim()} + + ) + i += 2 + + continue + } + } + + if (HR_RE.test(line)) { + start('rule') + nodes.push( + + {'─'.repeat(36)} + + ) + i++ + + continue + } + + const footnote = line.match(FOOTNOTE_RE) + + if (footnote) { + start('list') + nodes.push( + + [{footnote[1]}] + + ) + i++ + + while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { + nodes.push( + + + + + + ) + i++ + } + + continue + } + + if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { + start('list') + nodes.push( + + {line.trim()} + + ) + i++ + + while (i < lines.length) { + const def = lines[i]!.match(DEF_RE) + + if (!def) { + break + } + + nodes.push( + + · + + + ) + i++ + } + + continue + } + + const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/) if (bullet) { start('list') + const depth = indentDepth(bullet[1]!) + const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/) + const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' + const body = task ? task[2]! : bullet[2]! + nodes.push( - - + + {' '.repeat(depth * 2)} + {marker}{' '} + + ) i++ @@ -175,14 +433,19 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - const numbered = line.match(/^\s*(\d+)\.\s(.*)/) + const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/) if (numbered) { start('list') + const depth = indentDepth(numbered[1]!) + nodes.push( - {numbered[1]}. - + + {' '.repeat(depth * 2)} + {numbered[2]}.{' '} + + ) i++ @@ -190,12 +453,18 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } - if (line.match(/^>\s?/)) { + if (/^\s*(?:>\s*)+/.test(line)) { start('quote') - const quoteLines: string[] = [] + const quoteLines: Array<{ depth: number; text: string }> = [] - while (i < lines.length && lines[i]!.match(/^>\s?/)) { - quoteLines.push(lines[i]!.replace(/^>\s?/, '')) + while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) { + const raw = lines[i]! + const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? '' + + quoteLines.push({ + depth: (prefix.match(/>/g) ?? []).length, + text: raw.slice(prefix.length) + }) i++ } @@ -203,8 +472,9 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st {quoteLines.map((ql, qi) => ( - {' │ '} - + {' '.repeat(Math.max(0, ql.depth - 1) * 2)} + {'│ '} + ))} @@ -213,6 +483,55 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st continue } + if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) { + start('table') + const tableRows: string[][] = [] + + tableRows.push(splitTableRow(line)) + i += 2 + + while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) { + tableRows.push(splitTableRow(lines[i]!)) + i++ + } + + nodes.push(renderTable(key, tableRows, t)) + + continue + } + + if (/^/i.test(line)) { + i++ + + continue + } + + const summary = line.match(/^(.*?)<\/summary>$/i) + + if (summary) { + start('paragraph') + nodes.push( + + ▶ {summary[1]} + + ) + i++ + + continue + } + + if (/^<\/?[^>]+>$/.test(line.trim())) { + start('paragraph') + nodes.push( + + {line.trim()} + + ) + i++ + + continue + } + if (line.includes('|') && line.trim().startsWith('|')) { start('table') const tableRows: string[][] = [] @@ -221,29 +540,14 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const row = lines[i]!.trim() if (!/^[|\s:-]+$/.test(row)) { - tableRows.push( - row - .split('|') - .filter(Boolean) - .map(c => c.trim()) - ) + tableRows.push(splitTableRow(row)) } i++ } if (tableRows.length) { - const widths = tableRows[0]!.map((_, ci) => Math.max(...tableRows.map(r => (r[ci] ?? '').length))) - - nodes.push( - - {tableRows.map((row, ri) => ( - - {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} - - ))} - - ) + nodes.push(renderTable(key, tableRows, t)) } continue diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index fb42943184..461fbc8b00 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -19,14 +19,21 @@ const renderEstimateLine = (line: string) => { } return line + .replace(/!\[(.*?)\]\(([^)\s]+)\)/g, '[image: $1]') .replace(/\[(.+?)\]\((https?:\/\/[^\s)]+)\)/g, '$1') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') .replace(/\*(.+?)\*/g, '$1') - .replace(/^#{1,3}\s+/, '') - .replace(/^\s*[-*]\s+/, '• ') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/==(.+?)==/g, '$1') + .replace(/\[\^([^\]]+)\]/g, '[$1]') + .replace(/^#{1,6}\s+/, '') + .replace(/^\s*[-*+]\s+\[( |x|X)\]\s+/, (_m, checked: string) => `• [${checked.toLowerCase() === 'x' ? 'x' : ' '}] `) + .replace(/^\s*[-*+]\s+/, '• ') .replace(/^\s*(\d+)\.\s+/, '$1. ') - .replace(/^>\s?/, '│ ') + .replace(/^\s*(?:>\s*)+/, '│ ') } export const compactPreview = (s: string, max: number) => { @@ -79,26 +86,34 @@ export const scaleHex = (hex: string, k: number) => { } export const estimateRows = (text: string, w: number, compact = false) => { - let inCode = false + let fence: { char: '`' | '~'; len: number } | null = null let rows = 0 for (const raw of text.split('\n')) { const line = stripAnsi(raw) + const maybeFence = line.match(/^\s*(`{3,}|~{3,})(.*)$/) - if (line.startsWith('```')) { - if (!inCode) { - const lang = line.slice(3).trim() + if (maybeFence) { + const marker = maybeFence[1]! + const lang = maybeFence[2]!.trim() + + if (!fence) { + fence = { + char: marker[0] as '`' | '~', + len: marker.length + } if (lang) { rows += Math.ceil((`─ ${lang}`.length || 1) / w) } + } else if (marker[0] === fence.char && marker.length >= fence.len) { + fence = null } - inCode = !inCode - continue } + const inCode = Boolean(fence) const trimmed = line.trim() if (!inCode && trimmed.startsWith('|') && /^[|\s:-]+$/.test(trimmed)) { From a1d2a0c0fd6c02bb8b2a3158b574bb7215d41540 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 11 Apr 2026 18:29:18 -0500 Subject: [PATCH 075/157] feat: self update npm deps on hermes update --- hermes_cli/main.py | 46 ++++++++++++++++++++++++----- tests/hermes_cli/test_cmd_update.py | 43 +++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 8 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 8d1a10000b..48bbdbf0d4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -3133,6 +3133,8 @@ def _update_via_zip(args): ) _install_python_dependencies_with_optional_fallback(pip_cmd) + _update_node_dependencies() + # Sync skills try: from tools.skills_sync import sync_skills @@ -3652,9 +3654,42 @@ def _install_python_dependencies_with_optional_fallback( print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}") +def _update_node_dependencies() -> None: + npm = shutil.which("npm") + if not npm: + return + + paths = ( + ("repo root", PROJECT_ROOT), + ("ui-tui", PROJECT_ROOT / "ui-tui"), + ) + if not any((path / "package.json").exists() for _, path in paths): + return + + print("→ Updating Node.js dependencies...") + for label, path in paths: + if not (path / "package.json").exists(): + continue + + result = subprocess.run( + [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], + cwd=path, + capture_output=True, + text=True, + check=False, + ) + if result.returncode == 0: + print(f" ✓ {label}") + continue + + print(f" ⚠ npm install failed in {label}") + stderr = (result.stderr or "").strip() + if stderr: + print(f" {stderr.splitlines()[-1]}") + + def cmd_update(args): """Update Hermes Agent to the latest version.""" - import shutil from hermes_cli.config import is_managed, managed_error if is_managed(): @@ -3873,13 +3908,8 @@ def cmd_update(args): ) _install_python_dependencies_with_optional_fallback(pip_cmd) - # Check for Node.js deps - if (PROJECT_ROOT / "package.json").exists(): - import shutil - if shutil.which("npm"): - print("→ Updating Node.js dependencies...") - subprocess.run(["npm", "install", "--silent"], cwd=PROJECT_ROOT, check=False) - + _update_node_dependencies() + print() print("✓ Code updated!") diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 9ffa809a5e..c8f284228b 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -106,6 +106,49 @@ class TestCmdUpdateBranchFallback: pull_cmds = [c for c in commands if "pull" in c] assert len(pull_cmds) == 0 + @patch("shutil.which") + @patch("subprocess.run") + def test_update_refreshes_repo_and_tui_node_dependencies( + self, mock_run, mock_which, mock_args + ): + mock_which.side_effect = {"uv": "/usr/bin/uv", "npm": "/usr/bin/npm"}.get + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="1" + ) + + cmd_update(mock_args) + + npm_calls = [ + (call.args[0], call.kwargs.get("cwd")) + for call in mock_run.call_args_list + if call.args and call.args[0][0] == "/usr/bin/npm" + ] + + assert npm_calls == [ + ( + [ + "/usr/bin/npm", + "install", + "--silent", + "--no-fund", + "--no-audit", + "--progress=false", + ], + PROJECT_ROOT, + ), + ( + [ + "/usr/bin/npm", + "install", + "--silent", + "--no-fund", + "--no-audit", + "--progress=false", + ], + PROJECT_ROOT / "ui-tui", + ), + ] + def test_update_non_interactive_skips_migration_prompt(self, mock_args, capsys): """When stdin/stdout aren't TTYs, config migration prompt is skipped.""" with patch("shutil.which", return_value=None), patch( From 29721fcc589650f5f29b185d42ffb5bab90b2705 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Sat, 11 Apr 2026 15:58:22 -0400 Subject: [PATCH 076/157] nix fixes --- hermes_cli/main.py | 16 ++++++++++------ nix/tui.nix | 12 ++++++++---- ui-tui/src/gatewayClient.ts | 4 ++-- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 48bbdbf0d4..f147ed10bc 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -665,6 +665,13 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: sys.exit(1) return path + # pre-built dist (nix / HERMES_TUI_DIR) needs no npm at all. + if not tui_dev: + bundled = _find_bundled_tui(tui_dir) + if bundled: + node = _node_bin("node") + return [node, str(bundled / "dist" / "entry.js")], bundled + npm = _node_bin("npm") if not (tui_dir / "node_modules").exists(): print("Installing TUI dependencies…") @@ -703,11 +710,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [str(tsx), "src/entry.tsx"], tui_dir return [npm, "start"], tui_dir - env_bundle = os.environ.get("HERMES_TUI_DIR") - uses_packaged_dist = bool( - env_bundle and (Path(env_bundle) / "dist" / "entry.js").exists() - ) - if not uses_packaged_dist and _tui_build_needed(tui_dir): + if _tui_build_needed(tui_dir): result = subprocess.run( [npm, "run", "build"], cwd=str(tui_dir), @@ -735,7 +738,8 @@ def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): tui_dir = PROJECT_ROOT / "ui-tui" env = os.environ.copy() - env["HERMES_ROOT"] = os.environ.get("HERMES_ROOT", str(PROJECT_ROOT)) + env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get("HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT)) + env.setdefault("HERMES_CWD", os.getcwd()) if resume_session_id: env["HERMES_TUI_RESUME"] = resume_session_id diff --git a/nix/tui.nix b/nix/tui.nix index a077dc2d43..93973019f5 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-QQixyLmsn5+Y1daHifzDaNQbaoZjm+ezGrGoLXcc95U="; + hash = "sha256-+EhRRuvXi5hJupseHblF+MGxs84ijRMIH4qt5+2yYi8="; }; packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json")); @@ -28,6 +28,10 @@ pkgs.buildNpmPackage { # runtime node_modules cp -r node_modules $out/lib/hermes-tui/node_modules + # @hermes/ink is a file: dependency, we need to copy it in fr + rm -f $out/lib/hermes-tui/node_modules/@hermes/ink + cp -r packages/hermes-ink $out/lib/hermes-tui/node_modules/@hermes/ink + # package.json needed for "type": "module" resolution cp package.json $out/lib/hermes-tui/ @@ -36,7 +40,7 @@ pkgs.buildNpmPackage { nativeBuildInputs = [ (pkgs.writeShellScriptBin "update_tui_lockfile" '' - set -euo pipefail + set -euox pipefail # get root of repo REPO_ROOT=$(git rev-parse --show-toplevel) @@ -45,7 +49,7 @@ pkgs.buildNpmPackage { cd "$REPO_ROOT/ui-tui" rm -rf node_modules/ npm cache clean --force - npm install + CI=true npm install # ci env var to suppress annoying unicode install banner lag ${pkgs.lib.getExe npm-lockfile-fix} ./package-lock.json NIX_FILE="$REPO_ROOT/nix/tui.nix" @@ -65,7 +69,7 @@ pkgs.buildNpmPackage { STAMP_VALUE="${npmLockHash}" if [ ! -f "$STAMP" ] || [ "$(cat "$STAMP")" != "$STAMP_VALUE" ]; then echo "hermes-tui: installing npm dependencies..." - cd ui-tui && npm install --silent --no-fund --no-audit 2>/dev/null && cd .. + cd ui-tui && CI=true npm install --silent --no-fund --no-audit 2>/dev/null && cd .. mkdir -p .nix-stamps echo "$STAMP_VALUE" > "$STAMP" fi diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 5a3eac5e82..fb26d9b5e3 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -24,10 +24,10 @@ export class GatewayClient extends EventEmitter { private pending = new Map() start() { - const root = process.env.HERMES_ROOT ?? resolve(import.meta.dirname, '../../') + const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], { - cwd: root, + cwd: process.env.HERMES_CWD || root, stdio: ['pipe', 'pipe', 'pipe'] }) From 8e0df1d5323a1bfe55a1c1c9f2086034ddb3e97d Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Sat, 11 Apr 2026 20:23:15 -0400 Subject: [PATCH 077/157] launch tui later to allow setup et al --- hermes_cli/main.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index f147ed10bc..c9c2471dd9 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -793,12 +793,6 @@ def cmd_chat(args): # If resolution fails, keep the original value — _init_agent will # report "Session not found" with the original input - if use_tui: - _launch_tui( - getattr(args, "resume", None), - tui_dev=getattr(args, "tui_dev", False), - ) - # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() @@ -848,6 +842,13 @@ def cmd_chat(args): if getattr(args, "source", None): os.environ["HERMES_SESSION_SOURCE"] = args.source + + if use_tui: + _launch_tui( + getattr(args, "resume", None), + tui_dev=getattr(args, "tui_dev", False), + ) + # Import and run the CLI from cli import main as cli_main From 90890f8f04a00097c8f5b2a859af176a6320ec89 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Sat, 11 Apr 2026 22:10:02 -0400 Subject: [PATCH 078/157] feat: personality selector --- hermes_cli/commands.py | 31 +++++++++++++++ tui_gateway/server.py | 59 ++++++++++++++++++++++++++--- ui-tui/src/app.tsx | 28 ++++++++++---- ui-tui/src/components/textInput.tsx | 33 ++-------------- 4 files changed, 107 insertions(+), 44 deletions(-) diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 4ae35d36c1..14865fca18 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -897,6 +897,34 @@ class SlashCommandCompleter(Completer): except Exception: pass + @staticmethod + def _personality_completions(sub_text: str, sub_lower: str): + """Yield completions for /personality from configured personalities.""" + try: + from hermes_cli.config import load_config + personalities = load_config().get("agent", {}).get("personalities", {}) + if "none".startswith(sub_lower) and "none" != sub_lower: + yield Completion( + "none", + start_position=-len(sub_text), + display="none", + display_meta="clear personality overlay", + ) + for name, prompt in personalities.items(): + if name.startswith(sub_lower) and name != sub_lower: + if isinstance(prompt, dict): + meta = prompt.get("description") or prompt.get("system_prompt", "")[:50] + else: + meta = str(prompt)[:50] + yield Completion( + name, + start_position=-len(sub_text), + display=name, + display_meta=meta, + ) + except Exception: + pass + def _model_completions(self, sub_text: str, sub_lower: str): """Yield completions for /model from config aliases + built-in aliases.""" seen = set() @@ -959,6 +987,9 @@ class SlashCommandCompleter(Completer): if base_cmd == "/skin": yield from self._skin_completions(sub_text, sub_lower) return + if base_cmd == "/personality": + yield from self._personality_completions(sub_text, sub_lower) + return # Static subcommand completions if " " not in sub_text and base_cmd in SUBCOMMANDS and self._command_allowed(base_cmd): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8d58df25c7..1121211d9c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -502,10 +502,35 @@ def _wire_callbacks(sid: str): set_secret_capture_callback(secret_cb) +def _resolve_personality_prompt(cfg: dict) -> str: + """Resolve the active personality into a system prompt string.""" + name = (cfg.get("display", {}).get("personality", "") or "").strip().lower() + if not name or name in ("default", "none", "neutral"): + return "" + try: + from hermes_cli.config import load_config as _load_full_cfg + personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) + except Exception: + personalities = cfg.get("agent", {}).get("personalities", {}) + pval = personalities.get(name) + if pval is None: + return "" + if isinstance(pval, dict): + parts = [pval.get("system_prompt", "")] + if pval.get("tone"): + parts.append(f'Tone: {pval["tone"]}') + if pval.get("style"): + parts.append(f'Style: {pval["style"]}') + return "\n".join(p for p in parts if p) + return str(pval) + + def _make_agent(sid: str, key: str, session_id: str | None = None): from run_agent import AIAgent cfg = _load_cfg() system_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" + if not system_prompt: + system_prompt = _resolve_personality_prompt(cfg) return AIAgent( model=_resolve_model(), quiet_mode=True, @@ -1218,16 +1243,36 @@ def _(rid, params: dict) -> dict: else: cfg["custom_prompt"] = value nv = value + _save_cfg(cfg) elif key == "personality": - cfg.setdefault("display", {})["personality"] = value if value not in ("none", "default", "neutral") else "" + pname = value if value not in ("none", "default", "neutral") else "" + _write_config_key("display.personality", pname) + cfg = _load_cfg() + new_prompt = _resolve_personality_prompt(cfg) + _write_config_key("agent.system_prompt", new_prompt) nv = value + sid_key = params.get("session_id", "") + if session: + try: + new_agent = _make_agent(sid_key, session["session_key"], session_id=session["session_key"]) + session["agent"] = new_agent + with session["history_lock"]: + session["history"] = [] + session["history_version"] = int(session.get("history_version", 0)) + 1 + except Exception: + if session.get("agent"): + agent = session["agent"] + agent.ephemeral_system_prompt = new_prompt or None + agent._cached_system_prompt = None else: - cfg.setdefault("display", {})[key] = value + _write_config_key(f"display.{key}", value) nv = value - _save_cfg(cfg) - if key == "skin": - _emit("skin.changed", "", resolve_skin()) - return _ok(rid, {"key": key, "value": nv}) + if key == "skin": + _emit("skin.changed", "", resolve_skin()) + resp = {"key": key, "value": nv} + if key == "personality": + resp["cleared"] = True + return _ok(rid, resp) except Exception as e: return _err(rid, 5001, str(e)) @@ -1255,6 +1300,8 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"prompt": _load_cfg().get("custom_prompt", "")}) if key == "skin": return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) + if key == "personality": + return _ok(rid, {"value": _load_cfg().get("display", {}).get("personality", "default")}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index c32692210a..6fe380cb91 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1037,9 +1037,13 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (completions.length && input && historyIdx === null && (key.upArrow || key.downArrow)) { + if (completions.length && input && (key.upArrow || key.downArrow)) { setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)) + if (historyIdx !== null) { + setHistoryIdx(null) + } + return } @@ -1828,13 +1832,16 @@ export function App({ gw }: { gw: GatewayClient }) { case 'personality': if (arg) { - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => - sys(`personality: ${r.value || 'default'}`) - ) + rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { + if (r?.cleared) { + setMessages([]) + setHistoryItems([]) + } + + sys(`personality → ${r?.value}`) + }) } else { - gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) - .catch(() => sys('personality command failed')) + rpc('config.get', { key: 'personality' }).then((r: any) => sys(`personality: ${r?.value || 'default'}`)) } return true @@ -2190,6 +2197,11 @@ export function App({ gw }: { gw: GatewayClient }) { const submit = useCallback( (value: string) => { + if (completions.length && completions[compIdx]) { + value = value.slice(0, compReplace) + completions[compIdx].text + setInput(value) + } + if (!value.trim() && !inputBuf.length) { const now = Date.now() const dbl = now - lastEmptyAt.current < 450 @@ -2247,7 +2259,7 @@ export function App({ gw }: { gw: GatewayClient }) { dispatchSubmission([...inputBuf, value].join('\n')) }, - [dequeue, dispatchSubmission, inputBuf, sid] + [compIdx, compReplace, completions, dequeue, dispatchSubmission, inputBuf, sid] ) // ── Derived ────────────────────────────────────────────────────── diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index b41a0b27e5..cb64c42841 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -8,7 +8,7 @@ type InkExt = typeof Ink & { } const ink = Ink as unknown as InkExt -const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink +const { Box, Text, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink // ── ANSI escapes ───────────────────────────────────────────────────── @@ -17,7 +17,6 @@ const INV = `${ESC}[7m` const INV_OFF = `${ESC}[27m` const DIM = `${ESC}[2m` const DIM_OFF = `${ESC}[22m` -const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') @@ -121,31 +120,6 @@ function renderWithCursor(value: string, cursor: number) { return done ? out : out + invert(' ') } -// ── Forward-delete detection hook ──────────────────────────────────── - -function useFwdDelete(active: boolean) { - const ref = useRef(false) - const { inputEmitter: ee } = useStdin() - - useEffect(() => { - if (!active) { - return - } - - const h = (d: string) => { - ref.current = FWD_DEL_RE.test(d) - } - - ee.prependListener('input', h) - - return () => { - ee.removeListener('input', h) - } - }, [active, ee]) - - return ref -} - // ── Types ──────────────────────────────────────────────────────────── export interface PasteEvent { @@ -170,7 +144,6 @@ interface Props { export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { const [cur, setCur] = useState(value.length) - const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() const curRef = useRef(cur) @@ -363,7 +336,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl } // Deletion - else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { + else if (k.backspace && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -372,7 +345,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl v = v.slice(0, c - 1) + v.slice(c) c-- } - } else if (k.delete && fwdDel.current && c < v.length) { + } else if (k.delete && c < v.length) { if (mod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) From 3bf0f39337c0a8bf6468a9af5ff32a010f386625 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Sun, 12 Apr 2026 16:53:53 -0400 Subject: [PATCH 079/157] wrap preformatted ansi in component --- ui-tui/src/components/messageLine.tsx | 4 ++-- ui-tui/src/types/hermes-ink.d.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index b32f03cd78..a05c9fc0ab 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Ansi, Box, Text } from '@hermes/ink' import { memo } from 'react' import { LONG_MSG, ROLE } from '../constants.js' @@ -40,7 +40,7 @@ export const MessageLine = memo(function MessageLine({ } if (msg.role !== 'user' && hasAnsi(msg.text)) { - return {msg.text} + return {msg.text} } if (msg.role === 'assistant') { diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 7c8a8a7246..81faab32ea 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -45,6 +45,7 @@ declare module '@hermes/ink' { } export const Box: React.ComponentType + export const Ansi: React.ComponentType export const Text: React.ComponentType export const TextInput: React.ComponentType export const stringWidth: (s: string) => number From ef51bb00911266996a03da6c4237d8e08ff5f9ee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Apr 2026 16:06:39 -0500 Subject: [PATCH 080/157] fix: tool drafting stuff --- ui-tui/src/app.tsx | 48 ++++++++++++++++++++++++------------------ ui-tui/src/lib/text.ts | 4 ++++ 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index ee317662af..6280041ca3 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -26,6 +26,7 @@ import { fmtK, hasInterpolation, isToolTrailResultLine, + isTransientTrailLine, pick, sameToolTrailGroup } from './lib/text.js' @@ -453,13 +454,27 @@ export function App({ gw }: { gw: GatewayClient }) { }) }, []) + const pruneTransient = useCallback(() => { + setTurnTrail(prev => { + const next = prev.filter(l => !isTransientTrailLine(l)) + + if (next.length === prev.length) { + return prev + } + + turnToolsRef.current = next + + return next + }) + }, []) + const pushTrail = useCallback((line: string) => { setTurnTrail(prev => { if (prev.at(-1) === line) { return prev } - const next = [...prev, line].slice(-8) + const next = [...prev.filter(l => !isTransientTrailLine(l)), line].slice(-8) turnToolsRef.current = next return next @@ -1037,13 +1052,9 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (completions.length && input && (key.upArrow || key.downArrow)) { + if (completions.length && input && historyIdx === null && (key.upArrow || key.downArrow)) { setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)) - if (historyIdx !== null) { - setHistoryIdx(null) - } - return } @@ -1332,6 +1343,7 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'tool.start': + pruneTransient() setTools(prev => [ ...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } @@ -1416,6 +1428,8 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.delta': + pruneTransient() + if (p?.text && !interruptedRef.current) { buf.current = p.rendered ?? buf.current + p.text setStreaming(buf.current.trimStart()) @@ -1843,16 +1857,13 @@ export function App({ gw }: { gw: GatewayClient }) { case 'personality': if (arg) { - rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { - if (r?.cleared) { - setMessages([]) - setHistoryItems([]) - } - - sys(`personality → ${r?.value}`) - }) + rpc('config.set', { key: 'personality', value: arg }).then((r: any) => + sys(`personality: ${r.value || 'default'}`) + ) } else { - rpc('config.get', { key: 'personality' }).then((r: any) => sys(`personality: ${r?.value || 'default'}`)) + gw.request('slash.exec', { command: 'personality', session_id: sid }) + .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) + .catch(() => sys('personality command failed')) } return true @@ -2280,11 +2291,6 @@ export function App({ gw }: { gw: GatewayClient }) { const submit = useCallback( (value: string) => { - if (completions.length && completions[compIdx]) { - value = value.slice(0, compReplace) + completions[compIdx].text - setInput(value) - } - if (!value.trim() && !inputBuf.length) { const now = Date.now() const dbl = now - lastEmptyAt.current < 450 @@ -2342,7 +2348,7 @@ export function App({ gw }: { gw: GatewayClient }) { dispatchSubmission([...inputBuf, value].join('\n')) }, - [compIdx, compReplace, completions, dequeue, dispatchSubmission, inputBuf, sid] + [dequeue, dispatchSubmission, inputBuf, sid] ) // ── Derived ────────────────────────────────────────────────────── diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 461fbc8b00..3c5ccbcc7d 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -53,6 +53,10 @@ export const buildToolTrailLine = (name: string, context: string, error?: boolea /** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') +/** Ephemeral status lines that should vanish once the next phase starts. */ +export const isTransientTrailLine = (line: string) => + line.startsWith('drafting ') || line === 'analyzing tool output…' + /** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`) From 8efd3db1b4fdb9468cf5c29ff9cef31b22e18642 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Apr 2026 16:08:03 -0500 Subject: [PATCH 081/157] fix: force builds --- hermes_cli/main.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index a287dadf9d..8bef611b0e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -786,10 +786,10 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: # pre-built dist (nix / HERMES_TUI_DIR) needs no npm at all. if not tui_dev: - bundled = _find_bundled_tui(tui_dir) - if bundled: + ext_dir = os.environ.get("HERMES_TUI_DIR") + if ext_dir and (Path(ext_dir) / "dist" / "entry.js").exists(): node = _node_bin("node") - return [node, str(bundled / "dist" / "entry.js")], bundled + return [node, str(Path(ext_dir) / "dist" / "entry.js")], Path(ext_dir) npm = _node_bin("npm") if not (tui_dir / "node_modules").exists(): From 4b026d6761ede4541ca312dd8a1e341859d7510a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Apr 2026 16:31:30 -0500 Subject: [PATCH 082/157] fix: little box typey thing --- ui-tui/src/app.tsx | 71 +++++++++++++++++--------- ui-tui/src/components/maskedPrompt.tsx | 7 ++- ui-tui/src/components/messageLine.tsx | 8 +++ ui-tui/src/components/prompts.tsx | 68 +++++++++++++----------- ui-tui/src/components/textInput.tsx | 48 +++++++++++++++-- ui-tui/src/types.ts | 2 +- 6 files changed, 142 insertions(+), 62 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 6280041ca3..45feafff5b 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -368,6 +368,7 @@ export function App({ gw }: { gw: GatewayClient }) { const pasteCounterRef = useRef(0) const colsRef = useRef(cols) const turnToolsRef = useRef([]) + const persistedToolLabelsRef = useRef>(new Set()) const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) @@ -454,31 +455,19 @@ export function App({ gw }: { gw: GatewayClient }) { }) }, []) + const setTrail = (next: string[]) => { turnToolsRef.current = next; return next } + const pruneTransient = useCallback(() => { setTurnTrail(prev => { const next = prev.filter(l => !isTransientTrailLine(l)) - - if (next.length === prev.length) { - return prev - } - - turnToolsRef.current = next - - return next + return next.length === prev.length ? prev : setTrail(next) }) }, []) const pushTrail = useCallback((line: string) => { - setTurnTrail(prev => { - if (prev.at(-1) === line) { - return prev - } - - const next = [...prev.filter(l => !isTransientTrailLine(l)), line].slice(-8) - turnToolsRef.current = next - - return next - }) + setTurnTrail(prev => + prev.at(-1) === line ? prev : setTrail([...prev.filter(l => !isTransientTrailLine(l)), line].slice(-8)) + ) }, []) const rpc = useCallback( @@ -489,6 +478,31 @@ export function App({ gw }: { gw: GatewayClient }) { [gw, sys] ) + const answerClarify = useCallback( + (answer: string) => { + if (!clarify) return + + const label = TOOL_VERBS.clarify ?? 'clarify' + + setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l))) + setTurnTrail(turnToolsRef.current) + + gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) + + if (answer) { + persistedToolLabelsRef.current.add(label) + appendMessage({ role: 'system', text: '', kind: 'trail', tools: [buildToolTrailLine('clarify', clarify.question)] }) + appendMessage({ role: 'user', text: answer }) + } else { + sys('prompt cancelled') + } + + setClarify(null) + setStatus('running…') + }, + [appendMessage, clarify, gw, sys] + ) + useEffect(() => { if (!sid) { return @@ -1030,7 +1044,9 @@ export function App({ gw }: { gw: GatewayClient }) { } if (ctrl(key, ch, 'c')) { - if (approval) { + if (clarify) { + answerClarify('') + } else if (approval) { gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) setApproval(null) sys('denied') @@ -1276,6 +1292,7 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity([]) setTurnTrail([]) turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() break @@ -1439,7 +1456,9 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.complete': { const wasInterrupted = interruptedRef.current const savedReasoning = reasoningRef.current.trim() - const savedTools = turnToolsRef.current.filter(isToolTrailResultLine) + const persisted = persistedToolLabelsRef.current + const savedTools = turnToolsRef.current + .filter(l => isToolTrailResultLine(l) && ![...persisted].some(p => sameToolTrailGroup(p, l))) const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart() idle() @@ -1465,6 +1484,7 @@ export function App({ gw }: { gw: GatewayClient }) { } turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() setActivity([]) buf.current = '' @@ -1494,6 +1514,7 @@ export function App({ gw }: { gw: GatewayClient }) { setReasoning('') setActivity([]) turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() setStatus('ready') break @@ -2412,11 +2433,9 @@ export function App({ gw }: { gw: GatewayClient }) { {clarify && ( { - gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) - appendMessage({ role: 'user', text: answer }) - setClarify(null) - }} + cols={cols} + onAnswer={answerClarify} + onCancel={() => answerClarify('')} req={clarify} t={theme} /> @@ -2441,6 +2460,7 @@ export function App({ gw }: { gw: GatewayClient }) { {sudo && ( { @@ -2456,6 +2476,7 @@ export function App({ gw }: { gw: GatewayClient }) { {secret && ( { diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index 60e4ed16ae..c9e1100995 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -1,15 +1,18 @@ -import { Box, Text, TextInput } from '@hermes/ink' +import { Box, Text } from '@hermes/ink' import { useState } from 'react' import type { Theme } from '../theme.js' +import { TextInput } from './textInput.js' export function MaskedPrompt({ + cols = 80, icon, label, onSubmit, sub, t }: { + cols?: number icon: string label: string onSubmit: (v: string) => void @@ -27,7 +30,7 @@ export function MaskedPrompt({ {'> '} - + ) diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index a05c9fc0ab..07a8fbd5af 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -20,6 +20,14 @@ export const MessageLine = memo(function MessageLine({ msg: Msg t: Theme }) { + if (msg.kind === 'trail' && msg.tools?.length) { + return ( + + + + ) + } + if (msg.role === 'tool') { const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 05c97665c3..69a2bb8a85 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -1,8 +1,9 @@ -import { Box, Text, TextInput, useInput } from '@hermes/ink' +import { Box, Text, useInput } from '@hermes/ink' import { useState } from 'react' import type { Theme } from '../theme.js' import type { ApprovalReq, ClarifyReq } from '../types.js' +import { TextInput } from './textInput.js' export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) { const [sel, setSel] = useState(3) @@ -59,68 +60,77 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => ) } -export function ClarifyPrompt({ onAnswer, req, t }: { onAnswer: (s: string) => void; req: ClarifyReq; t: Theme }) { +export function ClarifyPrompt({ + cols = 80, + onAnswer, + onCancel, + req, + t +}: { + cols?: number + onAnswer: (s: string) => void + onCancel: () => void + req: ClarifyReq + t: Theme +}) { const [sel, setSel] = useState(0) const [custom, setCustom] = useState('') const [typing, setTyping] = useState(false) const choices = req.choices ?? [] + const heading = ( + + ask + {req.question} + + ) + useInput((ch, key) => { - if (typing) { + if (key.escape) { + typing && choices.length ? setTyping(false) : onCancel() return } - if (key.upArrow && sel > 0) { - setSel(s => s - 1) - } + if (typing) return - if (key.downArrow && sel < choices.length) { - setSel(s => s + 1) - } + 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]!) - } + sel === choices.length ? setTyping(true) : choices[sel] && onAnswer(choices[sel]!) } const n = parseInt(ch) - - if (n >= 1 && n <= choices.length) { - onAnswer(choices[n - 1]!) - } + if (n >= 1 && n <= choices.length) onAnswer(choices[n - 1]!) }) if (typing || !choices.length) { return ( - - ❓ {req.question} - + {heading} + {'> '} - + + + Enter send · Esc back · Ctrl+C cancel ) } return ( - - ❓ {req.question} - + {heading} + {[...choices, 'Other (type your answer)'].map((c, i) => ( {sel === i ? '▸ ' : ' '} - - {i + 1}. {c} - + {i + 1}. {c} ))} - ↑/↓ select · Enter confirm · 1-{choices.length} quick pick + + ↑/↓ select · Enter confirm · 1-{choices.length} quick pick · Esc/Ctrl+C cancel ) } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index ec87ec4f31..6378a55c48 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -9,7 +9,7 @@ type InkExt = typeof Ink & { } const ink = Ink as unknown as InkExt -const { Box, Text, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink +const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink // ── ANSI escapes ───────────────────────────────────────────────────── @@ -18,6 +18,7 @@ const INV = `${ESC}[7m` const INV_OFF = `${ESC}[27m` const DIM = `${ESC}[2m` const DIM_OFF = `${ESC}[22m` +const FWD_DEL_RE = new RegExp(`${ESC}\\[3(?:[~$^]|;)`) const PRINTABLE = /^[ -~\u00a0-\uffff]+$/ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') @@ -121,6 +122,31 @@ function renderWithCursor(value: string, cursor: number) { return done ? out : out + invert(' ') } +// ── Forward-delete detection hook ──────────────────────────────────── + +function useFwdDelete(active: boolean) { + const ref = useRef(false) + const { inputEmitter: ee } = useStdin() + + useEffect(() => { + if (!active) { + return + } + + const h = (d: string) => { + ref.current = FWD_DEL_RE.test(d) + } + + ee.prependListener('input', h) + + return () => { + ee.removeListener('input', h) + } + }, [active, ee]) + + return ref +} + // ── Types ──────────────────────────────────────────────────────────── export interface PasteEvent { @@ -137,14 +163,25 @@ interface Props { onChange: (v: string) => void onSubmit?: (v: string) => void onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null + mask?: string placeholder?: string focus?: boolean } // ── Component ──────────────────────────────────────────────────────── -export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, placeholder = '', focus = true }: Props) { +export function TextInput({ + columns = 80, + value, + onChange, + onPaste, + onSubmit, + mask, + placeholder = '', + focus = true +}: Props) { const [cur, setCur] = useState(value.length) + const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() const curRef = useRef(cur) @@ -163,7 +200,8 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl cbSubmit.current = onSubmit cbPaste.current = onPaste - const display = self.current ? vRef.current : value + const raw = self.current ? vRef.current : value + const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw // ── Cursor declaration ─────────────────────────────────────────── @@ -337,7 +375,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl } // Deletion - else if (k.backspace && c > 0) { + else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -346,7 +384,7 @@ export function TextInput({ columns = 80, value, onChange, onPaste, onSubmit, pl v = v.slice(0, c - 1) + v.slice(c) c-- } - } else if (k.delete && c < v.length) { + } else if (k.delete && fwdDel.current && c < v.length) { if (mod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 9507f41ca1..ddffc566c5 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -25,7 +25,7 @@ export interface ClarifyReq { export interface Msg { role: Role text: string - kind?: 'intro' | 'panel' | 'slash' + kind?: 'intro' | 'panel' | 'slash' | 'trail' info?: SessionInfo panelData?: PanelData thinking?: string From e03bef684eb912cf968ba2178bf7afcab926a19a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Apr 2026 16:33:25 -0500 Subject: [PATCH 083/157] chore: fmt --- ui-tui/src/app.tsx | 25 ++++++++++++++++----- ui-tui/src/components/markdown.tsx | 30 ++++++++------------------ ui-tui/src/components/maskedPrompt.tsx | 1 + ui-tui/src/components/prompts.tsx | 24 ++++++++++++++++----- ui-tui/src/lib/text.ts | 3 +-- 5 files changed, 50 insertions(+), 33 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 45feafff5b..bdb1c82798 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -455,11 +455,16 @@ export function App({ gw }: { gw: GatewayClient }) { }) }, []) - const setTrail = (next: string[]) => { turnToolsRef.current = next; return next } + const setTrail = (next: string[]) => { + turnToolsRef.current = next + + return next + } const pruneTransient = useCallback(() => { setTurnTrail(prev => { const next = prev.filter(l => !isTransientTrailLine(l)) + return next.length === prev.length ? prev : setTrail(next) }) }, []) @@ -480,7 +485,9 @@ export function App({ gw }: { gw: GatewayClient }) { const answerClarify = useCallback( (answer: string) => { - if (!clarify) return + if (!clarify) { + return + } const label = TOOL_VERBS.clarify ?? 'clarify' @@ -491,7 +498,12 @@ export function App({ gw }: { gw: GatewayClient }) { if (answer) { persistedToolLabelsRef.current.add(label) - appendMessage({ role: 'system', text: '', kind: 'trail', tools: [buildToolTrailLine('clarify', clarify.question)] }) + appendMessage({ + role: 'system', + text: '', + kind: 'trail', + tools: [buildToolTrailLine('clarify', clarify.question)] + }) appendMessage({ role: 'user', text: answer }) } else { sys('prompt cancelled') @@ -1457,8 +1469,11 @@ export function App({ gw }: { gw: GatewayClient }) { const wasInterrupted = interruptedRef.current const savedReasoning = reasoningRef.current.trim() const persisted = persistedToolLabelsRef.current - const savedTools = turnToolsRef.current - .filter(l => isToolTrailResultLine(l) && ![...persisted].some(p => sameToolTrailGroup(p, l))) + + const savedTools = turnToolsRef.current.filter( + l => isToolTrailResultLine(l) && ![...persisted].some(p => sameToolTrailGroup(p, l)) + ) + const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart() idle() diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 8d5cf888fe..6a59227734 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -10,11 +10,11 @@ const FOOTNOTE_RE = /^\[\^([^\]]+)\]:\s*(.*)$/ const DEF_RE = /^\s*:\s+(.+)$/ const TABLE_DIVIDER_CELL_RE = /^:?-{3,}:?$/ const MD_URL_RE = '((?:[^\\s()]|\\([^\\s()]*\\))+?)' -const INLINE_RE = - new RegExp( - `(!\\[(.*?)\\]\\(${MD_URL_RE}\\)|\\[(.+?)\\]\\(${MD_URL_RE}\\)|<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|__(.+?)__|\\*(.+?)\\*|_(.+?)_|==(.+?)==|\\[\\^([^\\]]+)\\]|\\^([^^\\s][^^]*?)\\^|~([^~\\s][^~]*?)~|(https?:\\/\\/[^\\s<]+))`, - 'g' - ) + +const INLINE_RE = new RegExp( + `(!\\[(.*?)\\]\\(${MD_URL_RE}\\)|\\[(.+?)\\]\\(${MD_URL_RE}\\)|<((?:https?:\\/\\/|mailto:)[^>\\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,})>|~~(.+?)~~|\`([^\\\`]+)\`|\\*\\*(.+?)\\*\\*|__(.+?)__|\\*(.+?)\\*|_(.+?)_|==(.+?)==|\\[\\^([^\\]]+)\\]|\\^([^^\\s][^^]*?)\\^|~([^~\\s][^~]*?)~|(https?:\\/\\/[^\\s<]+))`, + 'g' +) type Fence = { char: '`' | '~' @@ -171,11 +171,7 @@ function MdInline({ t, text }: { t: Theme; text: string }) { parts.push(renderAutolink(parts.length, t, url)) if (tail) { - parts.push( - - {tail} - - ) + parts.push({tail}) } } @@ -193,16 +189,8 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st const lines = text.split('\n') const nodes: ReactNode[] = [] let i = 0 - let prevKind: - | 'blank' - | 'code' - | 'heading' - | 'list' - | 'paragraph' - | 'quote' - | 'rule' - | 'table' - | null = null + + let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null const gap = () => { if (nodes.length && prevKind !== 'blank') { @@ -400,7 +388,7 @@ export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: st nodes.push( - · + · ) diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index c9e1100995..f159cc681f 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -2,6 +2,7 @@ import { Box, Text } from '@hermes/ink' import { useState } from 'react' import type { Theme } from '../theme.js' + import { TextInput } from './textInput.js' export function MaskedPrompt({ diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 69a2bb8a85..4e546f3d8b 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import type { Theme } from '../theme.js' import type { ApprovalReq, ClarifyReq } from '../types.js' + import { TextInput } from './textInput.js' export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) { @@ -88,20 +89,31 @@ export function ClarifyPrompt({ useInput((ch, key) => { if (key.escape) { typing && choices.length ? setTyping(false) : onCancel() + return } - if (typing) return + if (typing) { + return + } - if (key.upArrow && sel > 0) setSel(s => s - 1) - if (key.downArrow && sel < choices.length) setSel(s => s + 1) + if (key.upArrow && sel > 0) { + setSel(s => s - 1) + } + + if (key.downArrow && sel < choices.length) { + setSel(s => s + 1) + } if (key.return) { sel === choices.length ? setTyping(true) : choices[sel] && onAnswer(choices[sel]!) } const n = parseInt(ch) - if (n >= 1 && n <= choices.length) onAnswer(choices[n - 1]!) + + if (n >= 1 && n <= choices.length) { + onAnswer(choices[n - 1]!) + } }) if (typing || !choices.length) { @@ -126,7 +138,9 @@ export function ClarifyPrompt({ {[...choices, 'Other (type your answer)'].map((c, i) => ( {sel === i ? '▸ ' : ' '} - {i + 1}. {c} + + {i + 1}. {c} + ))} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 3c5ccbcc7d..82a87c91f2 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -54,8 +54,7 @@ export const buildToolTrailLine = (name: string, context: string, error?: boolea export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') /** Ephemeral status lines that should vanish once the next phase starts. */ -export const isTransientTrailLine = (line: string) => - line.startsWith('drafting ') || line === 'analyzing tool output…' +export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…' /** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => From ddb0871769144475be11d8540d5b81ffdb73ab50 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Apr 2026 17:39:17 -0500 Subject: [PATCH 084/157] feat(tui): hierarchical tool progress with grouped parent/child rows and transient line pruning --- tui_gateway/server.py | 78 +++++++- ui-tui/src/app.tsx | 56 +++--- ui-tui/src/components/thinking.tsx | 286 ++++++++++++++++++----------- ui-tui/src/lib/text.ts | 51 ++++- 4 files changed, 333 insertions(+), 138 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1121211d9c..f5b3ad73ac 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -4,6 +4,7 @@ import os import subprocess import sys import threading +import time import uuid from datetime import datetime from pathlib import Path @@ -279,6 +280,10 @@ def _session_tool_progress_mode(sid: str) -> str: return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all") +def _reasoning_visible(sid: str) -> bool: + return _session_show_reasoning(sid) or _session_tool_progress_mode(sid) == "verbose" + + def _tool_progress_enabled(sid: str) -> bool: return _session_tool_progress_mode(sid) != "off" @@ -436,6 +441,49 @@ def _tool_ctx(name: str, args: dict) -> str: return "" +def _fmt_tool_duration(seconds: float | None) -> str: + if seconds is None: + return "" + if seconds < 10: + return f"{seconds:.1f}s" + if seconds < 60: + return f"{round(seconds)}s" + mins, secs = divmod(int(round(seconds)), 60) + return f"{mins}m {secs}s" if secs else f"{mins}m" + + +def _count_list(obj: object, *path: str) -> int | None: + cur = obj + for key in path: + if not isinstance(cur, dict): + return None + cur = cur.get(key) + return len(cur) if isinstance(cur, list) else None + + +def _tool_summary(name: str, result: str, duration_s: float | None) -> str | None: + try: + data = json.loads(result) + except Exception: + data = None + + dur = _fmt_tool_duration(duration_s) + suffix = f" in {dur}" if dur else "" + text = None + + if name == "web_search" and isinstance(data, dict): + n = _count_list(data, "data", "web") + if n is not None: + text = f"Did {n} {'search' if n == 1 else 'searches'}" + + elif name == "web_extract" and isinstance(data, dict): + n = _count_list(data, "results") or _count_list(data, "data", "results") + if n is not None: + text = f"Extracted {n} {'page' if n == 1 else 'pages'}" + + return f"{text or 'Completed'}{suffix}" if (text or dur) else None + + def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session = _sessions.get(sid) if session is not None: @@ -447,6 +495,7 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict): session.setdefault("edit_snapshots", {})[tool_call_id] = snapshot except Exception: pass + session.setdefault("tool_started_at", {})[tool_call_id] = time.time() if _tool_progress_enabled(sid): _emit("tool.start", sid, {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}) @@ -455,8 +504,16 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result payload = {"tool_id": tool_call_id, "name": name} session = _sessions.get(sid) snapshot = None + started_at = None if session is not None: snapshot = session.setdefault("edit_snapshots", {}).pop(tool_call_id, None) + started_at = session.setdefault("tool_started_at", {}).pop(tool_call_id, None) + duration_s = time.time() - started_at if started_at else None + if duration_s is not None: + payload["duration_s"] = duration_s + summary = _tool_summary(name, result, duration_s) + if summary: + payload["summary"] = summary try: from agent.display import render_edit_diff_with_delta @@ -469,15 +526,29 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result _emit("tool.complete", sid, payload) +def _on_tool_progress( + sid: str, + event_type: str, + name: str | None = None, + preview: str | None = None, + _args: dict | None = None, + **_kwargs, +): + if not _tool_progress_enabled(sid) or event_type != "tool.started" or not name: + return + _emit("tool.progress", sid, {"name": name, "preview": preview or ""}) + + def _agent_cbs(sid: str) -> dict: return dict( tool_start_callback=lambda tc_id, name, args: _on_tool_start(sid, tc_id, name, args), tool_complete_callback=lambda tc_id, name, args, result: _on_tool_complete(sid, tc_id, name, args, result), - tool_progress_callback=lambda name, preview, args: _tool_progress_enabled(sid) - and _emit("tool.progress", sid, {"name": name, "preview": preview}), + tool_progress_callback=lambda event_type, name=None, preview=None, args=None, **kwargs: _on_tool_progress( + sid, event_type, name, preview, args, **kwargs + ), tool_gen_callback=lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), - reasoning_callback=lambda text: _session_show_reasoning(sid) and _emit("reasoning.delta", sid, {"text": text}), + reasoning_callback=lambda text: _reasoning_visible(sid) and _emit("reasoning.delta", sid, {"text": text}), status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)), clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), ) @@ -559,6 +630,7 @@ def _init_session(sid: str, key: str, agent, history: list, cols: int = 80): "show_reasoning": _load_show_reasoning(), "tool_progress_mode": _load_tool_progress_mode(), "edit_snapshots": {}, + "tool_started_at": {}, } try: _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index bdb1c82798..0cf63fd53c 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -13,8 +13,8 @@ import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' import { type PasteEvent, TextInput } from './components/textInput.js' -import { Thinking, ToolTrail } from './components/thinking.js' -import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, TOOL_VERBS, ZERO } from './constants.js' +import { ToolTrail } from './components/thinking.js' +import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' @@ -28,7 +28,8 @@ import { isToolTrailResultLine, isTransientTrailLine, pick, - sameToolTrailGroup + sameToolTrailGroup, + toolTrailLabel } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' import type { @@ -324,8 +325,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [sid, setSid] = useState(null) const [theme, setTheme] = useState(DEFAULT_THEME) const [info, setInfo] = useState(null) - const [thinking, setThinking] = useState(false) - const [turnKey, setTurnKey] = useState(0) const [activity, setActivity] = useState([]) const [tools, setTools] = useState([]) const [busy, setBusy] = useState(false) @@ -489,7 +488,7 @@ export function App({ gw }: { gw: GatewayClient }) { return } - const label = TOOL_VERBS.clarify ?? 'clarify' + const label = toolTrailLabel('clarify') setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l))) setTurnTrail(turnToolsRef.current) @@ -554,7 +553,6 @@ export function App({ gw }: { gw: GatewayClient }) { }, [pushActivity, rpc, sid]) const idle = () => { - setThinking(false) setTools([]) setTurnTrail([]) setBusy(false) @@ -1297,8 +1295,6 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'message.start': - setThinking(true) - setTurnKey(k => k + 1) setBusy(true) setReasoning('') setActivity([]) @@ -1384,9 +1380,14 @@ export function App({ gw }: { gw: GatewayClient }) { setTools(prev => { const done = prev.find(t => t.id === p.tool_id) const name = done?.name ?? p.name - const ctx = (p.error as string) || done?.context || '' - const label = TOOL_VERBS[name] ?? name - const line = buildToolTrailLine(name, ctx, !!p.error) + const label = toolTrailLabel(name) + + const line = buildToolTrailLine( + name, + done?.context || '', + !!p.error, + (p.error as string) || (p.summary as string) || '' + ) toolCompleteRibbonRef.current = { label, line } const remaining = prev.filter(t => t.id !== p.tool_id) @@ -2400,6 +2401,10 @@ export function App({ gw }: { gw: GatewayClient }) { const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : '' const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` + const showProgressArea = Boolean( + (busy && !streaming) || (busy ? activity.length : 0) || tools.length || turnTrail.length + ) + const showStreamingArea = Boolean(streaming) // ── Render ─────────────────────────────────────────────────────── @@ -2421,18 +2426,23 @@ export function App({ gw }: { gw: GatewayClient }) { ))} - + {showProgressArea && ( + + + + )} - {busy && !tools.length && !streaming && } - - {streaming && ( - + {showStreamingArea && ( + + + )} {pasteReview && ( diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 9ec53ddc0d..a5f876da1f 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,16 +1,17 @@ -import { Text } from '@hermes/ink' -import { memo, useEffect, useState } from 'react' +import { Box, Text } from '@hermes/ink' +import { memo, type ReactNode, useEffect, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' -import { FACES, TOOL_VERBS, VERBS } from '../constants.js' +import { FACES, VERBS } from '../constants.js' import { - isToolTrailResultLine, - lastCotTrailIndex, + formatToolCall, + parseToolTrailResultLine, pick, scaleHex, THINKING_COT_FADE, THINKING_COT_MAX, - thinkingCotTail + thinkingCotTail, + toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' import type { ActiveTool, ActivityItem } from '../types.js' @@ -18,19 +19,14 @@ import type { ActiveTool, ActivityItem } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] -const tone = (item: ActivityItem, t: Theme) => - item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim - -const activityGlyph = (item: ActivityItem) => (item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·') - -const TreeFork = ({ last }: { last: boolean }) => {last ? '└─ ' : '├─ '} - const fmtElapsed = (ms: number) => { const sec = Math.max(0, ms) / 1000 return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } +// ── Spinner ────────────────────────────────────────────────────────── + export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const [spin] = useState(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] @@ -49,100 +45,20 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: return {spin.frames[frame]} } -export const ToolTrail = memo(function ToolTrail({ - t, - tools = [], - trail = [], - activity = [], - animateCot = false -}: { - t: Theme - tools?: ActiveTool[] - trail?: string[] - activity?: ActivityItem[] - animateCot?: boolean -}) { - const [now, setNow] = useState(() => Date.now()) +// ── Detail row ─────────────────────────────────────────────────────── - useEffect(() => { - if (!tools.length) { - return - } - - const id = setInterval(() => setNow(Date.now()), 200) - - return () => clearInterval(id) - }, [tools.length]) - - if (!trail.length && !tools.length && !activity.length) { - return null - } - - const act = activity.slice(-4) - const rowCount = trail.length + tools.length + act.length - const activeCotIdx = animateCot && !tools.length ? lastCotTrailIndex(trail) : -1 +type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string } +function Detail({ color, content, dimColor, t }: DetailRow & { t: Theme }) { return ( - <> - {trail.map((line, i) => { - const lastInBlock = i === rowCount - 1 - - if (isToolTrailResultLine(line)) { - return ( - - - {line} - - ) - } - - if (i === activeCotIdx) { - return ( - - - {line} - - ) - } - - return ( - - - {line} - - ) - })} - - {tools.map((tool, j) => { - const lastInBlock = trail.length + j === rowCount - 1 - - return ( - - - {TOOL_VERBS[tool.name] ?? tool.name} - {tool.context ? `: ${tool.context}` : ''} - {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} - - ) - })} - - {act.map((item, k) => { - const lastInBlock = trail.length + tools.length + k === rowCount - 1 - - return ( - - - {activityGlyph(item)} {item.text} - - ) - })} - + + + {content} + ) -}) +} + +// ── Thinking (pre-tool fallback) ───────────────────────────────────── export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) { const [tick, setTick] = useState(0) @@ -157,7 +73,7 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st const clipped = reasoning.length > THINKING_COT_MAX return ( - <> + {FACES[tick % FACES.length] ?? '(•_•)'}{' '} {VERBS[tick % VERBS.length] ?? 'thinking'}… @@ -177,6 +93,166 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st ) : null} - + + ) +}) + +// ── ToolTrail (canonical progress block) ───────────────────────────── + +type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string } + +export const ToolTrail = memo(function ToolTrail({ + busy = false, + reasoning = '', + t, + tools = [], + trail = [], + activity = [] +}: { + busy?: boolean + reasoning?: string + t: Theme + tools?: ActiveTool[] + trail?: string[] + activity?: ActivityItem[] +}) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + if (!tools.length) { + return + } + const id = setInterval(() => setNow(Date.now()), 200) + + return () => clearInterval(id) + }, [tools.length]) + + if (!busy && !trail.length && !tools.length && !activity.length) { + return null + } + + const groups: Group[] = [] + const meta: DetailRow[] = [] + + const detail = (row: DetailRow) => { + const g = groups.at(-1) + g ? g.details.push(row) : meta.push(row) + } + + // ── trail → groups + details ──────────────────────────────────── + + for (const [i, line] of trail.entries()) { + const parsed = parseToolTrailResultLine(line) + + if (parsed) { + groups.push({ + color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, + content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, + details: [], + key: `tr-${i}` + }) + + if (parsed.detail) { + detail({ + color: parsed.mark === '✗' ? t.color.error : t.color.dim, + content: parsed.detail, + dimColor: parsed.mark !== '✗', + key: `tr-${i}-d` + }) + } + + continue + } + + if (line.startsWith('drafting ')) { + groups.push({ + color: t.color.cornsilk, + content: toolTrailLabel(line.slice(9).replace(/…$/, '').trim()), + details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], + key: `tr-${i}` + }) + + continue + } + + if (line === 'analyzing tool output…') { + detail({ + color: t.color.dim, + content: groups.length ? ( + <> + {line} + + ) : ( + line + ), + dimColor: true, + key: `tr-${i}` + }) + + continue + } + + meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` }) + } + + // ── live tools → groups ───────────────────────────────────────── + + for (const tool of tools) { + groups.push({ + color: t.color.cornsilk, + content: ( + <> + {formatToolCall(tool.name, tool.context || '')} + {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} + + ), + details: [], + key: tool.id + }) + } + + // ── reasoning tail → child of last group ──────────────────────── + + const reasoningTail = thinkingCotTail(reasoning) + + if (groups.length && reasoningTail) { + detail({ color: t.color.dim, content: reasoningTail, dimColor: true, key: 'cot' }) + } + + // ── activity → meta ───────────────────────────────────────────── + + for (const item of activity.slice(-4)) { + const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·' + const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim + + meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` }) + } + + // ── render ────────────────────────────────────────────────────── + + return ( + + {busy && !groups.length && } + + {groups.map(g => ( + + + + {g.content} + + + {g.details.map(d => ( + + ))} + + ))} + + {meta.map((row, i) => ( + + {i === meta.length - 1 ? '└ ' : '├ '} + {row.content} + + ))} + ) }) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 82a87c91f2..30dcd67e31 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,4 @@ -import { INTERPOLATION_RE, LONG_MSG, TOOL_VERBS } from '../constants.js' +import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*m/g @@ -42,23 +42,60 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } -/** Build a single tool trail line — used by both live tool.complete and resume replay. */ -export const buildToolTrailLine = (name: string, context: string, error?: boolean): string => { - const label = TOOL_VERBS[name] ?? name - const mark = error ? '✗' : '✓' +export const toolTrailLabel = (name: string) => + name + .split('_') + .filter(Boolean) + .map(p => p[0]!.toUpperCase() + p.slice(1)) + .join(' ') || name - return `${label}${context ? ': ' + compactPreview(context, 72) : ''} ${mark}` +export const formatToolCall = (name: string, context = '') => { + const preview = compactPreview(context, 64) + + return preview ? `${toolTrailLabel(name)}("${preview}")` : toolTrailLabel(name) +} + +export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string): string => { + const detail = compactPreview(note ?? '', 72) + + return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}` } /** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') +export const parseToolTrailResultLine = (line: string) => { + if (!isToolTrailResultLine(line)) { + return null + } + + const mark = line.endsWith(' ✗') ? '✗' : '✓' + const body = line.slice(0, -2) + const [call, detail] = body.split(' :: ', 2) + + if (detail != null) { + return { call, detail, mark } + } + + const legacy = body.indexOf(': ') + + if (legacy > 0) { + return { call: body.slice(0, legacy), detail: body.slice(legacy + 2), mark } + } + + return { call: body, detail: '', mark } +} + /** Ephemeral status lines that should vanish once the next phase starts. */ export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…' /** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => - entry === `${label} ✓` || entry === `${label} ✗` || entry.startsWith(`${label}:`) + entry === `${label} ✓` || + entry === `${label} ✗` || + entry.startsWith(`${label}(`) || + entry.startsWith(`${label} ::`) || + entry.startsWith(`${label}:`) /** Index of the last non-result trail line, or -1. */ export const lastCotTrailIndex = (trail: readonly string[]) => { From 0fd33a98cd57554cfad794ac116545f20effab6e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 12 Apr 2026 20:08:12 -0500 Subject: [PATCH 085/157] feat: ctrl t for diff thinking rendering types --- ui-tui/src/app.tsx | 26 +++++++++--- ui-tui/src/components/messageLine.tsx | 26 +++++++----- ui-tui/src/components/textInput.tsx | 1 + ui-tui/src/components/thinking.tsx | 59 ++++++++++++++------------- ui-tui/src/constants.ts | 1 + ui-tui/src/lib/text.ts | 22 ++++------ ui-tui/src/types.ts | 1 + 7 files changed, 77 insertions(+), 59 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 0cf63fd53c..e0ccff15bc 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -45,6 +45,7 @@ import type { SessionInfo, SlashCatalog, SudoReq, + ThinkingMode, Usage } from './types.js' @@ -351,6 +352,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [bellOnComplete, setBellOnComplete] = useState(false) const [clockNow, setClockNow] = useState(() => Date.now()) + const [thinkingMode, setThinkingMode] = useState('truncated') // ── Refs ───────────────────────────────────────────────────────── @@ -390,6 +392,7 @@ export function App({ gw }: { gw: GatewayClient }) { const empty = !messages.length const isBlocked = blocked() + const hasAnyThinking = Boolean(reasoning.trim() || historyItems.some(m => m.thinking?.trim())) // ── Resize RPC ─────────────────────────────────────────────────── @@ -1181,6 +1184,16 @@ export function App({ gw }: { gw: GatewayClient }) { return } + if (ctrl(key, ch, 't')) { + if (hasAnyThinking) { + setThinkingMode(mode => (mode === 'collapsed' ? 'truncated' : mode === 'truncated' ? 'full' : 'collapsed')) + } else { + sys('no thinking available') + } + + return + } + if (ctrl(key, ch, 'b')) { if (voiceRecording) { setVoiceRecording(false) @@ -2401,9 +2414,11 @@ export function App({ gw }: { gw: GatewayClient }) { const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : '' const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` - const showProgressArea = Boolean( - (busy && !streaming) || (busy ? activity.length : 0) || tools.length || turnTrail.length - ) + + const hasReasoning = Boolean(reasoning.trim()) + + const showProgressArea = Boolean(busy || tools.length || turnTrail.length || hasReasoning) + const showStreamingArea = Boolean(streaming) // ── Render ─────────────────────────────────────────────────────── @@ -2420,7 +2435,7 @@ export function App({ gw }: { gw: GatewayClient }) { ) : m.kind === 'panel' && m.panelData ? ( ) : ( - + )} ))} @@ -2431,8 +2446,9 @@ export function App({ gw }: { gw: GatewayClient }) { diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 07a8fbd5af..2ab0e22728 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -2,9 +2,9 @@ import { Ansi, Box, Text } from '@hermes/ink' import { memo } from 'react' import { LONG_MSG, ROLE } from '../constants.js' -import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js' +import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, thinkingPreview, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { Msg } from '../types.js' +import type { Msg, ThinkingMode } from '../types.js' import { Md } from './markdown.js' import { ToolTrail } from './thinking.js' @@ -12,18 +12,20 @@ import { ToolTrail } from './thinking.js' export const MessageLine = memo(function MessageLine({ cols, compact, + thinkingMode = 'truncated', msg, t }: { cols: number compact?: boolean + thinkingMode?: ThinkingMode msg: Msg t: Theme }) { if (msg.kind === 'trail' && msg.tools?.length) { return ( - + ) } @@ -41,6 +43,9 @@ export const MessageLine = memo(function MessageLine({ } const { body, glyph, prefix } = ROLE[msg.role](t) + const thinking = msg.thinking?.replace(/\n/g, ' ').trim() ?? '' + const preview = thinkingPreview(thinking, thinkingMode, Math.min(96, Math.max(32, cols - 18))) + const showThinkingPreview = Boolean(preview && !msg.tools?.length) const content = (() => { if (msg.kind === 'slash') { @@ -80,18 +85,19 @@ export const MessageLine = memo(function MessageLine({ marginBottom={msg.role === 'user' ? 1 : 0} marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0} > - {msg.thinking && ( - - 💭 {msg.thinking.replace(/\n/g, ' ').slice(0, 200)} - - )} - {msg.tools?.length ? ( - + ) : null} + {showThinkingPreview && ( + + {'└ '} + {preview} + + )} + diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 6378a55c48..385dd5f48b 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -329,6 +329,7 @@ export function TextInput({ k.upArrow || k.downArrow || (k.ctrl && inp === 'c') || + (k.ctrl && inp === 't') || k.tab || (k.shift && k.tab) || k.pageUp || diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index a5f876da1f..4e609f5e6e 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -7,14 +7,12 @@ import { formatToolCall, parseToolTrailResultLine, pick, - scaleHex, - THINKING_COT_FADE, THINKING_COT_MAX, - thinkingCotTail, + thinkingPreview, toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool, ActivityItem } from '../types.js' +import type { ActiveTool, ActivityItem, ThinkingMode } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] @@ -49,10 +47,10 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string } -function Detail({ color, content, dimColor, t }: DetailRow & { t: Theme }) { +function Detail({ color, content, dimColor }: DetailRow) { return ( - + {content} ) @@ -60,7 +58,15 @@ function Detail({ color, content, dimColor, t }: DetailRow & { t: Theme }) { // ── Thinking (pre-tool fallback) ───────────────────────────────────── -export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: string; t: Theme }) { +export const Thinking = memo(function Thinking({ + mode = 'truncated', + reasoning, + t +}: { + mode?: ThinkingMode + reasoning: string + t: Theme +}) { const [tick, setTick] = useState(0) useEffect(() => { @@ -69,8 +75,7 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st return () => clearInterval(id) }, []) - const tail = thinkingCotTail(reasoning) - const clipped = reasoning.length > THINKING_COT_MAX + const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX) return ( @@ -79,18 +84,10 @@ export const Thinking = memo(function Thinking({ reasoning, t }: { reasoning: st {VERBS[tick % VERBS.length] ?? 'thinking'}… - {tail ? ( - - {clipped && - Array.from({ length: Math.min(THINKING_COT_FADE, tail.length) }, (_, i) => ( - - {tail[i]} - - ))} - - - {clipped ? tail.slice(THINKING_COT_FADE) : tail} - + {preview ? ( + + + {preview} ) : null} @@ -103,6 +100,7 @@ type Group = { color: string; content: ReactNode; details: DetailRow[]; key: str export const ToolTrail = memo(function ToolTrail({ busy = false, + thinkingMode = 'truncated', reasoning = '', t, tools = [], @@ -110,6 +108,7 @@ export const ToolTrail = memo(function ToolTrail({ activity = [] }: { busy?: boolean + thinkingMode?: ThinkingMode reasoning?: string t: Theme tools?: ActiveTool[] @@ -122,12 +121,15 @@ export const ToolTrail = memo(function ToolTrail({ if (!tools.length) { return } + const id = setInterval(() => setNow(Date.now()), 200) return () => clearInterval(id) }, [tools.length]) - if (!busy && !trail.length && !tools.length && !activity.length) { + const reasoningTail = thinkingPreview(reasoning, thinkingMode, THINKING_COT_MAX) + + if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail) { return null } @@ -211,11 +213,7 @@ export const ToolTrail = memo(function ToolTrail({ }) } - // ── reasoning tail → child of last group ──────────────────────── - - const reasoningTail = thinkingCotTail(reasoning) - - if (groups.length && reasoningTail) { + if (reasoningTail && groups.length) { detail({ color: t.color.dim, content: reasoningTail, dimColor: true, key: 'cot' }) } @@ -232,7 +230,10 @@ export const ToolTrail = memo(function ToolTrail({ return ( - {busy && !groups.length && } + {busy && !groups.length && } + {!busy && !groups.length && reasoningTail && ( + + )} {groups.map(g => ( @@ -242,7 +243,7 @@ export const ToolTrail = memo(function ToolTrail({ {g.details.map(d => ( - + ))} ))} diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 59fc639282..9f1c487711 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -24,6 +24,7 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+D', 'exit'], ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'new session (clear)'], + ['Ctrl+T', 'cycle thinking detail'], ['Ctrl+V / Alt+V', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 30dcd67e31..9bed6c3c15 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,5 @@ import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' +import type { ThinkingMode } from '../types.js' // eslint-disable-next-line no-control-regex const ANSI_RE = /\x1b\[[0-9;]*m/g @@ -42,6 +43,12 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => { + const text = reasoning.replace(/\n/g, ' ').trim() + + return !text || mode === 'collapsed' ? '' : mode === 'full' ? text : compactPreview(text, max) +} + export const toolTrailLabel = (name: string) => name .split('_') @@ -109,21 +116,6 @@ export const lastCotTrailIndex = (trail: readonly string[]) => { } export const THINKING_COT_MAX = 160 -export const THINKING_COT_FADE = 5 - -export const thinkingCotTail = (reasoning: string) => reasoning.replace(/\n/g, ' ').slice(-THINKING_COT_MAX) - -/** Scale #RRGGBB by k ∈ [0,1] — used for left-edge fade toward terminal bg. */ -export const scaleHex = (hex: string, k: number) => { - const h = hex.replace('#', '') - - const ch = (o: number) => - Math.round(parseInt(h.slice(o, o + 2), 16) * k) - .toString(16) - .padStart(2, '0') - - return `#${ch(0)}${ch(2)}${ch(4)}` -} export const estimateRows = (text: string, w: number, compact = false) => { let fence: { char: '`' | '~'; len: number } | null = null diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index ddffc566c5..8f24ba7ac5 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -33,6 +33,7 @@ export interface Msg { } export type Role = 'assistant' | 'system' | 'tool' | 'user' +export type ThinkingMode = 'collapsed' | 'truncated' | 'full' export interface SessionInfo { cwd?: string From a2c0597ae4f960d8595c331e562a7e30da2f187b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 10:11:18 -0500 Subject: [PATCH 086/157] feat: show thinking indicator while inferencing --- ui-tui/src/app.tsx | 38 +++++++++++++++++++++++++ ui-tui/src/components/thinking.tsx | 45 ++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e0ccff15bc..a5a0d1296b 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -58,6 +58,7 @@ const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() const LARGE_PASTE = { chars: 8000, lines: 80 } const EXCERPT = { chars: 1200, lines: 14 } const MAX_HISTORY = 800 +const REASONING_PULSE_MS = 700 const SECRET_PATTERNS = [ /AKIA[0-9A-Z]{16}/g, @@ -337,6 +338,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [secret, setSecret] = useState(null) const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') + const [reasoningStreaming, setReasoningStreaming] = useState(false) const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [pastes, setPastes] = useState([]) @@ -370,6 +372,7 @@ export function App({ gw }: { gw: GatewayClient }) { const colsRef = useRef(cols) const turnToolsRef = useRef([]) const persistedToolLabelsRef = useRef>(new Set()) + const reasoningStreamingTimerRef = useRef | null>(null) const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) @@ -386,6 +389,36 @@ export function App({ gw }: { gw: GatewayClient }) { const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) + const pulseReasoningStreaming = useCallback(() => { + if (reasoningStreamingTimerRef.current) { + clearTimeout(reasoningStreamingTimerRef.current) + } + + setReasoningStreaming(true) + reasoningStreamingTimerRef.current = setTimeout(() => { + reasoningStreamingTimerRef.current = null + setReasoningStreaming(false) + }, REASONING_PULSE_MS) + }, []) + + const clearReasoningStreaming = useCallback(() => { + if (reasoningStreamingTimerRef.current) { + clearTimeout(reasoningStreamingTimerRef.current) + reasoningStreamingTimerRef.current = null + } + + setReasoningStreaming(false) + }, []) + + useEffect( + () => () => { + if (reasoningStreamingTimerRef.current) { + clearTimeout(reasoningStreamingTimerRef.current) + } + }, + [] + ) + function blocked() { return !!(clarify || approval || pasteReview || picker || secret || sudo || pager) } @@ -1356,6 +1389,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'reasoning.delta': if (p?.text) { setReasoning(prev => prev + p.text) + pulseReasoningStreaming() } break @@ -1382,6 +1416,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'tool.start': pruneTransient() + clearReasoningStreaming() setTools(prev => [ ...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } @@ -1472,6 +1507,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.delta': pruneTransient() + clearReasoningStreaming() if (p?.text && !interruptedRef.current) { buf.current = p.rendered ?? buf.current + p.text @@ -1492,6 +1528,7 @@ export function App({ gw }: { gw: GatewayClient }) { idle() setReasoning('') + clearReasoningStreaming() setStreaming('') if (inflightPasteIdsRef.current.length) { @@ -2447,6 +2484,7 @@ export function App({ gw }: { gw: GatewayClient }) { activity={busy ? activity : []} busy={busy && !streaming} reasoning={reasoning} + reasoningStreaming={reasoningStreaming} t={theme} thinkingMode={hasReasoning ? thinkingMode : 'truncated'} tools={tools} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 4e609f5e6e..c16b9fd654 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -56,15 +56,31 @@ function Detail({ color, content, dimColor }: DetailRow) { ) } +// ── Streaming cursor ───────────────────────────────────────────────── + +function StreamCursor({ active = false, color, dimColor }: { active?: boolean; color: string; dimColor?: boolean }) { + const [on, setOn] = useState(true) + + useEffect(() => { + const id = setInterval(() => setOn(v => !v), 420) + + return () => clearInterval(id) + }, []) + + return {active && on ? '▍' : ' '} +} + // ── Thinking (pre-tool fallback) ───────────────────────────────────── export const Thinking = memo(function Thinking({ mode = 'truncated', reasoning, + streaming = false, t }: { mode?: ThinkingMode reasoning: string + streaming?: boolean t: Theme }) { const [tick, setTick] = useState(0) @@ -88,6 +104,12 @@ export const Thinking = memo(function Thinking({ {preview} + + + ) : streaming ? ( + + + ) : null} @@ -102,6 +124,7 @@ export const ToolTrail = memo(function ToolTrail({ busy = false, thinkingMode = 'truncated', reasoning = '', + reasoningStreaming = false, t, tools = [], trail = [], @@ -110,6 +133,7 @@ export const ToolTrail = memo(function ToolTrail({ busy?: boolean thinkingMode?: ThinkingMode reasoning?: string + reasoningStreaming?: boolean t: Theme tools?: ActiveTool[] trail?: string[] @@ -214,7 +238,24 @@ export const ToolTrail = memo(function ToolTrail({ } if (reasoningTail && groups.length) { - detail({ color: t.color.dim, content: reasoningTail, dimColor: true, key: 'cot' }) + detail({ + color: t.color.dim, + content: ( + <> + {reasoningTail} + + + ), + dimColor: true, + key: 'cot' + }) + } else if (reasoningStreaming && groups.length && thinkingMode === 'collapsed') { + detail({ + color: t.color.dim, + content: , + dimColor: true, + key: 'cot' + }) } // ── activity → meta ───────────────────────────────────────────── @@ -230,7 +271,7 @@ export const ToolTrail = memo(function ToolTrail({ return ( - {busy && !groups.length && } + {busy && !groups.length && } {!busy && !groups.length && reasoningTail && ( )} From a27167fb30a57b2afe890ce1dd3984ba72a3b3dd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 10:14:05 -0500 Subject: [PATCH 087/157] chore: fmt --- ui-tui/src/components/thinking.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index c16b9fd654..4a1f4c7207 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -67,7 +67,11 @@ function StreamCursor({ active = false, color, dimColor }: { active?: boolean; c return () => clearInterval(id) }, []) - return {active && on ? '▍' : ' '} + return ( + + {active && on ? '▍' : ' '} + + ) } // ── Thinking (pre-tool fallback) ───────────────────────────────────── @@ -271,7 +275,9 @@ export const ToolTrail = memo(function ToolTrail({ return ( - {busy && !groups.length && } + {busy && !groups.length && ( + + )} {!busy && !groups.length && reasoningTail && ( )} From 713a614ea8e1f45e5c57fe1ea83a66fc98e54972 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 10:22:44 -0500 Subject: [PATCH 088/157] chore: uptick --- ui-tui/src/app.tsx | 28 +++++++++++++++---- ui-tui/src/components/thinking.tsx | 44 ++++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 17 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index a5a0d1296b..e70644ea6f 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -338,6 +338,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [secret, setSecret] = useState(null) const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') + const [reasoningActive, setReasoningActive] = useState(false) const [reasoningStreaming, setReasoningStreaming] = useState(false) const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') @@ -394,6 +395,7 @@ export function App({ gw }: { gw: GatewayClient }) { clearTimeout(reasoningStreamingTimerRef.current) } + setReasoningActive(true) setReasoningStreaming(true) reasoningStreamingTimerRef.current = setTimeout(() => { reasoningStreamingTimerRef.current = null @@ -401,13 +403,14 @@ export function App({ gw }: { gw: GatewayClient }) { }, REASONING_PULSE_MS) }, []) - const clearReasoningStreaming = useCallback(() => { + const endReasoningPhase = useCallback(() => { if (reasoningStreamingTimerRef.current) { clearTimeout(reasoningStreamingTimerRef.current) reasoningStreamingTimerRef.current = null } setReasoningStreaming(false) + setReasoningActive(false) }, []) useEffect( @@ -589,6 +592,7 @@ export function App({ gw }: { gw: GatewayClient }) { }, [pushActivity, rpc, sid]) const idle = () => { + endReasoningPhase() setTools([]) setTurnTrail([]) setBusy(false) @@ -1342,6 +1346,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.start': setBusy(true) + endReasoningPhase() setReasoning('') setActivity([]) setTurnTrail([]) @@ -1416,7 +1421,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'tool.start': pruneTransient() - clearReasoningStreaming() + endReasoningPhase() setTools(prev => [ ...prev, { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } @@ -1507,7 +1512,7 @@ export function App({ gw }: { gw: GatewayClient }) { case 'message.delta': pruneTransient() - clearReasoningStreaming() + endReasoningPhase() if (p?.text && !interruptedRef.current) { buf.current = p.rendered ?? buf.current + p.text @@ -1528,7 +1533,6 @@ export function App({ gw }: { gw: GatewayClient }) { idle() setReasoning('') - clearReasoningStreaming() setStreaming('') if (inflightPasteIdsRef.current.length) { @@ -1586,7 +1590,20 @@ export function App({ gw }: { gw: GatewayClient }) { break } }, - [appendMessage, dequeue, newSession, pushActivity, pushTrail, send, sys] + [ + appendMessage, + bellOnComplete, + dequeue, + endReasoningPhase, + newSession, + pruneTransient, + pulseReasoningStreaming, + pushActivity, + pushTrail, + send, + sys, + stdout + ] ) onEventRef.current = onEvent @@ -2484,6 +2501,7 @@ export function App({ gw }: { gw: GatewayClient }) { activity={busy ? activity : []} busy={busy && !streaming} reasoning={reasoning} + reasoningActive={reasoningActive} reasoningStreaming={reasoningStreaming} t={theme} thinkingMode={hasReasoning ? thinkingMode : 'truncated'} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 4a1f4c7207..1ed03f8104 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -58,7 +58,17 @@ function Detail({ color, content, dimColor }: DetailRow) { // ── Streaming cursor ───────────────────────────────────────────────── -function StreamCursor({ active = false, color, dimColor }: { active?: boolean; color: string; dimColor?: boolean }) { +function StreamCursor({ + color, + dimColor, + streaming = false, + visible = false +}: { + color: string + dimColor?: boolean + streaming?: boolean + visible?: boolean +}) { const [on, setOn] = useState(true) useEffect(() => { @@ -67,21 +77,23 @@ function StreamCursor({ active = false, color, dimColor }: { active?: boolean; c return () => clearInterval(id) }, []) - return ( + return visible ? ( - {active && on ? '▍' : ' '} + {streaming && on ? '▍' : ' '} - ) + ) : null } // ── Thinking (pre-tool fallback) ───────────────────────────────────── export const Thinking = memo(function Thinking({ + active = false, mode = 'truncated', reasoning, streaming = false, t }: { + active?: boolean mode?: ThinkingMode reasoning: string streaming?: boolean @@ -108,12 +120,12 @@ export const Thinking = memo(function Thinking({ {preview} - + - ) : streaming ? ( + ) : active ? ( - + ) : null} @@ -127,6 +139,7 @@ type Group = { color: string; content: ReactNode; details: DetailRow[]; key: str export const ToolTrail = memo(function ToolTrail({ busy = false, thinkingMode = 'truncated', + reasoningActive = false, reasoning = '', reasoningStreaming = false, t, @@ -136,6 +149,7 @@ export const ToolTrail = memo(function ToolTrail({ }: { busy?: boolean thinkingMode?: ThinkingMode + reasoningActive?: boolean reasoning?: string reasoningStreaming?: boolean t: Theme @@ -157,7 +171,7 @@ export const ToolTrail = memo(function ToolTrail({ const reasoningTail = thinkingPreview(reasoning, thinkingMode, THINKING_COT_MAX) - if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail) { + if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail && !reasoningActive) { return null } @@ -247,16 +261,16 @@ export const ToolTrail = memo(function ToolTrail({ content: ( <> {reasoningTail} - + ), dimColor: true, key: 'cot' }) - } else if (reasoningStreaming && groups.length && thinkingMode === 'collapsed') { + } else if (reasoningActive && groups.length && thinkingMode === 'collapsed') { detail({ color: t.color.dim, - content: , + content: , dimColor: true, key: 'cot' }) @@ -276,7 +290,13 @@ export const ToolTrail = memo(function ToolTrail({ return ( {busy && !groups.length && ( - + )} {!busy && !groups.length && reasoningTail && ( From eec1db36f7cfbef0856d31f1f69a93df9b88b742 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 10:43:42 -0500 Subject: [PATCH 089/157] chore: preserve commands --- ui-tui/README.md | 5 +++-- ui-tui/src/app.tsx | 31 ++++++++++++++++++++----------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/ui-tui/README.md b/ui-tui/README.md index 9992cd340c..0f4f14e3ef 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -110,7 +110,7 @@ Current input behavior is split across `app.tsx`, `components/textInput.tsx`, an | `Ctrl+K` | Delete from the cursor to the end of the line | | `Meta+B` / `Meta+F` | Move by word | | `!cmd` | Run a shell command through the gateway | -| `{!cmd}` | Inline shell interpolation before send or queue | +| `{!cmd}` | Inline shell interpolation before send; queued drafts keep the raw text until they are sent | Notes: @@ -147,7 +147,8 @@ Notes: - Slash commands and `!cmd` do not queue; they execute immediately even while a run is active. - Queue auto-drains after each assistant response, unless a queued item is currently being edited. - `Up/Down` prioritizes queued-message editing over history. History only activates when there is no queue to edit. -- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and the current run is interrupted first. +- Queued drafts keep their original `!cmd` and `{!cmd}` text while you edit them. Shell commands and interpolation run when the queued item is actually sent. +- If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes. - Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`. - Text pastes are captured into a local paste shelf and inserted as `[[paste:]]` tokens. Nothing is newline-flattened. - Small pastes default to `excerpt` mode. Larger pastes default to `attach` mode. diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e70644ea6f..76dd78b543 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -952,6 +952,23 @@ export function App({ gw }: { gw: GatewayClient }) { }) } + const sendQueued = (text: string) => { + if (text.startsWith('!')) { + shellExec(text.slice(1).trim()) + + return + } + + if (hasInterpolation(text)) { + setBusy(true) + interpolate(text, send) + + return + } + + send(text) + } + // ── Dispatch ───────────────────────────────────────────────────── const dispatchSubmission = useCallback( @@ -1017,14 +1034,12 @@ export function App({ gw }: { gw: GatewayClient }) { if (picked && busy && sid) { queueRef.current.unshift(picked) syncQueue() - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - setStatus('interrupting…') return } if (picked && sid) { - send(picked) + sendQueued(picked) } return @@ -1033,12 +1048,6 @@ export function App({ gw }: { gw: GatewayClient }) { pushHistory(full) if (busy) { - if (hasInterpolation(full)) { - interpolate(full, enqueue) - - return - } - enqueue(full) return @@ -1571,7 +1580,7 @@ export function App({ gw }: { gw: GatewayClient }) { const next = dequeue() if (next) { - send(next) + sendQueued(next) } break @@ -1600,7 +1609,7 @@ export function App({ gw }: { gw: GatewayClient }) { pulseReasoningStreaming, pushActivity, pushTrail, - send, + sendQueued, sys, stdout ] From 0642b6cc53d371308110c8eaeec7b1b7298c02ee Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 12:54:48 -0500 Subject: [PATCH 090/157] fix: clean newline paste thingy --- ui-tui/src/app.tsx | 20 +++++++++++++------- ui-tui/src/lib/text.test.ts | 18 ++++++++++++++++++ ui-tui/src/lib/text.ts | 2 ++ 3 files changed, 33 insertions(+), 7 deletions(-) create mode 100644 ui-tui/src/lib/text.test.ts diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 76dd78b543..e2df73a2e8 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -29,6 +29,7 @@ import { isTransientTrailLine, pick, sameToolTrailGroup, + stripTrailingPasteNewlines, toolTrailLabel } from './lib/text.js' import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' @@ -784,14 +785,19 @@ export function App({ gw }: { gw: GatewayClient }) { void paste(true) } - if (!text) { + const cleanedText = stripTrailingPasteNewlines(text) + + if (!cleanedText) { return null } - const lineCount = text.split('\n').length + const lineCount = cleanedText.split('\n').length - if (text.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { - return { cursor: cursor + text.length, value: value.slice(0, cursor) + text + value.slice(cursor) } + if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { + return { + cursor: cursor + cleanedText.length, + value: value.slice(0, cursor) + cleanedText + value.slice(cursor) + } } pasteCounterRef.current++ @@ -806,13 +812,13 @@ export function App({ gw }: { gw: GatewayClient }) { [ ...prev, { - charCount: text.length, + charCount: cleanedText.length, createdAt: Date.now(), id, - kind: classifyPaste(text), + kind: classifyPaste(cleanedText), lineCount, mode, - text + text: cleanedText } ].slice(-24) ) diff --git a/ui-tui/src/lib/text.test.ts b/ui-tui/src/lib/text.test.ts new file mode 100644 index 0000000000..1a3800ec76 --- /dev/null +++ b/ui-tui/src/lib/text.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' + +import { stripTrailingPasteNewlines } from './text.js' + +describe('stripTrailingPasteNewlines', () => { + it('removes trailing newline runs from pasted text', () => { + expect(stripTrailingPasteNewlines('alpha\n')).toBe('alpha') + expect(stripTrailingPasteNewlines('alpha\nbeta\n\n')).toBe('alpha\nbeta') + }) + + it('preserves interior newlines', () => { + expect(stripTrailingPasteNewlines('alpha\nbeta\ngamma')).toBe('alpha\nbeta\ngamma') + }) + + it('preserves newline-only pastes', () => { + expect(stripTrailingPasteNewlines('\n\n')).toBe('\n\n') + }) +}) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 9bed6c3c15..b38b8fbd27 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -49,6 +49,8 @@ export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: numb return !text || mode === 'collapsed' ? '' : mode === 'full' ? text : compactPreview(text, max) } +export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text) + export const toolTrailLabel = (name: string) => name .split('_') From 56524bb1d999d8a39b3a0cfb0ebeabe7f6540461 Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Mon, 13 Apr 2026 15:09:31 -0400 Subject: [PATCH 091/157] fix: nix local dev with tui --- nix/packages.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/nix/packages.nix b/nix/packages.nix index 924ce2d176..f39d9d0b2b 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -89,6 +89,7 @@ echo "$STAMP_VALUE" > "$STAMP" else source .venv/bin/activate + export HERMES_PYTHON=${hermesVenv}/bin/python3 fi ''; From cac1b1b724cd21246e66d0947e188001efaa7ffb Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 14:17:52 -0500 Subject: [PATCH 092/157] fix(ui-tui): surface RPC errors and guard invalid gateway responses --- ui-tui/src/__tests__/rpc.test.ts | 27 +++ ui-tui/src/app.tsx | 305 ++++++++++++++++++++---- ui-tui/src/components/sessionPicker.tsx | 30 ++- ui-tui/src/lib/rpc.ts | 21 ++ 4 files changed, 328 insertions(+), 55 deletions(-) create mode 100644 ui-tui/src/__tests__/rpc.test.ts create mode 100644 ui-tui/src/lib/rpc.ts diff --git a/ui-tui/src/__tests__/rpc.test.ts b/ui-tui/src/__tests__/rpc.test.ts new file mode 100644 index 0000000000..7980093a9e --- /dev/null +++ b/ui-tui/src/__tests__/rpc.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' + +describe('asRpcResult', () => { + it('keeps plain object payloads', () => { + expect(asRpcResult({ ok: true, value: 'x' })).toEqual({ ok: true, value: 'x' }) + }) + + it('rejects missing or non-object payloads', () => { + expect(asRpcResult(undefined)).toBeNull() + expect(asRpcResult(null)).toBeNull() + expect(asRpcResult('oops')).toBeNull() + expect(asRpcResult(['bad'])).toBeNull() + }) +}) + +describe('rpcErrorMessage', () => { + it('prefers Error messages', () => { + expect(rpcErrorMessage(new Error('boom'))).toBe('boom') + }) + + it('falls back for unknown errors', () => { + expect(rpcErrorMessage('broken')).toBe('broken') + expect(rpcErrorMessage({ code: 500 })).toBe('request failed') + }) +}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e2df73a2e8..fdb7f24e55 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -20,6 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' import { writeOsc52Clipboard } from './lib/osc52.js' +import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' import { buildToolTrailLine, compactPreview, @@ -515,10 +516,21 @@ export function App({ gw }: { gw: GatewayClient }) { }, []) const rpc = useCallback( - (method: string, params: Record = {}) => - gw.request(method, params).catch((e: Error) => { - sys(`error: ${e.message}`) - }), + async (method: string, params: Record = {}) => { + try { + const result = asRpcResult(await gw.request(method, params)) + + if (result) { + return result + } + + sys(`error: invalid response: ${method}`) + } catch (e) { + sys(`error: ${rpcErrorMessage(e)}`) + } + + return null + }, [gw, sys] ) @@ -579,7 +591,13 @@ export function App({ gw }: { gw: GatewayClient }) { if (configMtimeRef.current && next && next !== configMtimeRef.current) { configMtimeRef.current = next - rpc('reload.mcp', { session_id: sid }).then(() => pushActivity('MCP reloaded after config change')) + rpc('reload.mcp', { session_id: sid }).then(r => { + if (!r) { + return + } + + pushActivity('MCP reloaded after config change') + }) rpc('config.get', { key: 'full' }).then((cfg: any) => { setBellOnComplete(!!cfg?.config?.display?.bell_on_complete) }) @@ -675,7 +693,16 @@ export function App({ gw }: { gw: GatewayClient }) { setPicker(false) setStatus('resuming…') gw.request('session.resume', { cols: colsRef.current, session_id: id }) - .then((r: any) => { + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.resume') + setStatus('ready') + + return + } + resetSession() setSid(r.session_id) setSessionStartedAt(Date.now()) @@ -892,7 +919,15 @@ export function App({ gw }: { gw: GatewayClient }) { setStatus('running…') gw.request('shell.exec', { command: cmd }) - .then((r: any) => { + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: shell.exec') + + return + } + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() if (out) { @@ -944,7 +979,11 @@ export function App({ gw }: { gw: GatewayClient }) { matches.map(m => gw .request('shell.exec', { command: m[1]! }) - .then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim()) + .then((raw: any) => { + const r = asRpcResult(raw) + + return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() + }) .catch(() => '(error)') ) ).then(results => { @@ -1252,6 +1291,10 @@ export function App({ gw }: { gw: GatewayClient }) { setVoiceProcessing(true) rpc('voice.record', { action: 'stop' }) .then((r: any) => { + if (!r) { + return + } + const transcript = String(r?.text || '').trim() if (transcript) { @@ -1267,7 +1310,11 @@ export function App({ gw }: { gw: GatewayClient }) { }) } else { rpc('voice.record', { action: 'start' }) - .then(() => { + .then(r => { + if (!r) { + return + } + setVoiceRecording(true) setStatus('recording…') }) @@ -1315,7 +1362,13 @@ export function App({ gw }: { gw: GatewayClient }) { if (STARTUP_RESUME_ID) { setStatus('resuming…') gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) - .then((r: any) => { + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + throw new Error('invalid response: session.resume') + } + resetSession() setSid(r.session_id) setInfo(r.info ?? null) @@ -1329,9 +1382,10 @@ export function App({ gw }: { gw: GatewayClient }) { setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) setStatus('ready') }) - .catch(() => { + .catch((e: unknown) => { + sys(`resume failed: ${rpcErrorMessage(e)}`) setStatus('forging session…') - newSession('resume failed, started a new session') + newSession('started a new session') }) } else { setStatus('forging session…') @@ -1823,6 +1877,10 @@ export function App({ gw }: { gw: GatewayClient }) { } rpc('session.undo', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + if (r.removed > 0) { setMessages(prev => { const q = [...prev] @@ -1879,6 +1937,10 @@ export function App({ gw }: { gw: GatewayClient }) { } rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => { + if (!r?.task_id) { + return + } + setBgTasks(prev => new Set(prev).add(r.task_id)) sys(`bg ${r.task_id} started`) }) @@ -1892,7 +1954,11 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - rpc('prompt.btw', { session_id: sid, text: arg }).then(() => { + rpc('prompt.btw', { session_id: sid, text: arg }).then(r => { + if (!r) { + return + } + setBgTasks(prev => new Set(prev).add('btw:x')) sys('btw running…') }) @@ -1901,7 +1967,11 @@ export function App({ gw }: { gw: GatewayClient }) { case 'model': if (!arg) { - rpc('config.get', { key: 'provider' }).then((r: any) => + rpc('config.get', { key: 'provider' }).then((r: any) => { + if (!r) { + return + } + panel('Model', [ { rows: [ @@ -1910,10 +1980,14 @@ export function App({ gw }: { gw: GatewayClient }) { ] } ]) - ) + }) } else { rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( (r: any) => { + if (!r?.value) { + return + } + sys(`model → ${r.value}`) setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) } @@ -1940,62 +2014,100 @@ export function App({ gw }: { gw: GatewayClient }) { case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) .then((r: any) => page(r?.output || '(no output)', 'Provider')) - .catch(() => sys('provider command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true case 'skin': if (arg) { - rpc('config.set', { key: 'skin', value: arg }).then((r: any) => sys(`skin → ${r.value}`)) + rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { + if (!r?.value) { + return + } + + sys(`skin → ${r.value}`) + }) } else { - rpc('config.get', { key: 'skin' }).then((r: any) => sys(`skin: ${r.value || 'default'}`)) + rpc('config.get', { key: 'skin' }).then((r: any) => { + if (!r) { + return + } + + sys(`skin: ${r.value || 'default'}`) + }) } return true case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => + rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { + if (!r) { + return + } + sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - ) + }) return true case 'reasoning': - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => { + if (!r?.value) { + return + } + sys(`reasoning: ${r.value}`) - ) + }) return true case 'verbose': - rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => + rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { + if (!r?.value) { + return + } + sys(`verbose: ${r.value}`) - ) + }) return true case 'personality': if (arg) { - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => + rpc('config.set', { key: 'personality', value: arg }).then((r: any) => { + if (!r) { + return + } + sys(`personality: ${r.value || 'default'}`) - ) + }) } else { gw.request('slash.exec', { command: 'personality', session_id: sid }) .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) - .catch(() => sys('personality command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } return true case 'compress': - rpc('session.compress', { session_id: sid }).then((r: any) => + rpc('session.compress', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + sys(`compressed${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) - ) + }) return true case 'stop': - rpc('process.stop', {}).then((r: any) => sys(`killed ${r.killed ?? 0} process(es)`)) + rpc('process.stop', {}).then((r: any) => { + if (!r) { + return + } + + sys(`killed ${r.killed ?? 0} process(es)`) + }) return true @@ -2017,14 +2129,24 @@ export function App({ gw }: { gw: GatewayClient }) { case 'reload-mcp': case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP reloaded')) + rpc('reload.mcp', { session_id: sid }).then(r => { + if (!r) { + return + } + + sys('MCP reloaded') + }) return true case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => + rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { + if (!r) { + return + } + sys(`title: ${r.title || '(none)'}`) - ) + }) return true @@ -2075,18 +2197,34 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'save': - rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved: ${r.file}`)) + rpc('session.save', { session_id: sid }).then((r: any) => { + if (!r?.file) { + return + } + + sys(`saved: ${r.file}`) + }) return true case 'history': - rpc('session.history', { session_id: sid }).then((r: any) => sys(`${r.count} messages`)) + rpc('session.history', { session_id: sid }).then((r: any) => { + if (typeof r?.count !== 'number') { + return + } + + sys(`${r.count} messages`) + }) return true case 'profile': rpc('config.get', { key: 'profile' }).then((r: any) => { - const text = r.display || r.home + if (!r) { + return + } + + const text = r.display || r.home || '(unknown profile)' const lines = text.split('\n').filter(Boolean) if (lines.length <= 2) { @@ -2111,7 +2249,11 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'insights': - rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => + rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { + if (!r) { + return + } + panel('Insights', [ { rows: [ @@ -2121,7 +2263,7 @@ export function App({ gw }: { gw: GatewayClient }) { ] } ]) - ) + }) return true case 'rollback': { @@ -2129,6 +2271,10 @@ export function App({ gw }: { gw: GatewayClient }) { if (!sub || sub === 'list') { rpc('rollback.list', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + if (!r.checkpoints?.length) { return sys('no checkpoints') } @@ -2151,7 +2297,13 @@ export function App({ gw }: { gw: GatewayClient }) { session_id: sid, hash, ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => sys(r.rendered || r.diff || r.message || 'done')) + }).then((r: any) => { + if (!r) { + return + } + + sys(r.rendered || r.diff || r.message || 'done') + }) } return true @@ -2159,15 +2311,23 @@ export function App({ gw }: { gw: GatewayClient }) { case 'browser': { const [act, ...bArgs] = (arg || 'status').split(/\s+/) - rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => + rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => { + if (!r) { + return + } + sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - ) + }) return true } case 'plugins': rpc('plugins.list', {}).then((r: any) => { + if (!r) { + return + } + if (!r.plugins?.length) { return sys('no plugins') } @@ -2185,6 +2345,10 @@ export function App({ gw }: { gw: GatewayClient }) { if (!sub || sub === 'list') { rpc('skills.manage', { action: 'list' }).then((r: any) => { + if (!r) { + return + } + const sk = r.skills as Record | undefined if (!sk || !Object.keys(sk).length) { @@ -2206,6 +2370,10 @@ export function App({ gw }: { gw: GatewayClient }) { if (sub === 'browse') { const pg = parseInt(sArgs[0] ?? '1', 10) || 1 rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { + if (!r) { + return + } + if (!r.items?.length) { return sys('no skills found in the hub') } @@ -2238,7 +2406,7 @@ export function App({ gw }: { gw: GatewayClient }) { gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || '/skills: no output')) - .catch(() => sys(`skills: ${sub} failed`)) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true } @@ -2248,6 +2416,10 @@ export function App({ gw }: { gw: GatewayClient }) { case 'tasks': rpc('agents.list', {}) .then((r: any) => { + if (!r) { + return + } + const procs = r.processes ?? [] const running = procs.filter((p: any) => p.status === 'running') const finished = procs.filter((p: any) => p.status !== 'running') @@ -2273,7 +2445,7 @@ export function App({ gw }: { gw: GatewayClient }) { panel('Agents', sections) }) - .catch(() => sys('agents command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true @@ -2281,6 +2453,10 @@ export function App({ gw }: { gw: GatewayClient }) { if (!arg || arg === 'list') { rpc('cron.manage', { action: 'list' }) .then((r: any) => { + if (!r) { + return + } + const jobs = r.jobs ?? [] if (!jobs.length) { @@ -2296,11 +2472,11 @@ export function App({ gw }: { gw: GatewayClient }) { } ]) }) - .catch(() => sys('cron command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } else { gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || '(no output)')) - .catch(() => sys('cron command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } return true @@ -2308,6 +2484,10 @@ export function App({ gw }: { gw: GatewayClient }) { case 'config': rpc('config.show', {}) .then((r: any) => { + if (!r) { + return + } + panel( 'Config', (r.sections ?? []).map((s: any) => ({ @@ -2316,13 +2496,17 @@ export function App({ gw }: { gw: GatewayClient }) { })) ) }) - .catch(() => sys('config command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true case 'tools': rpc('tools.list', { session_id: sid }) .then((r: any) => { + if (!r) { + return + } + if (!r.toolsets?.length) { return sys('no tools') } @@ -2335,13 +2519,17 @@ export function App({ gw }: { gw: GatewayClient }) { })) ) }) - .catch(() => sys('tools command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true case 'toolsets': rpc('toolsets.list', { session_id: sid }) .then((r: any) => { + if (!r) { + return + } + if (!r.toolsets?.length) { return sys('no toolsets') } @@ -2358,16 +2546,24 @@ export function App({ gw }: { gw: GatewayClient }) { } ]) }) - .catch(() => sys('toolsets command failed')) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => sys(r?.output || `/${name}: no output`)) - .catch(() => { + .catch((e: unknown) => { gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) - .then((d: any) => { + .then((raw: any) => { + const d = asRpcResult(raw) + + if (!d?.type) { + sys(`error: ${rpcErrorMessage(e)}`) + + return + } + if (d.type === 'exec') { sys(d.output || '(no output)') } else if (d.type === 'alias') { @@ -2376,10 +2572,15 @@ export function App({ gw }: { gw: GatewayClient }) { sys(d.output || '(no output)') } else if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`) - send(d.message) + + if (typeof d.message === 'string' && d.message.trim()) { + send(d.message) + } else { + sys(`/${name}: skill payload missing message`) + } } }) - .catch(() => sys(`unknown command: /${name}`)) + .catch(() => sys(`error: ${rpcErrorMessage(e)}`)) }) return true diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index 27a837794a..b97c6dd7a4 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -2,6 +2,7 @@ import { Box, Text, useInput } from '@hermes/ink' import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' interface SessionItem { @@ -41,16 +42,30 @@ export function SessionPicker({ t: Theme }) { const [items, setItems] = useState([]) + const [err, setErr] = useState('') const [sel, setSel] = useState(0) const [loading, setLoading] = useState(true) useEffect(() => { gw.request('session.list', { limit: 20 }) - .then((r: any) => { - setItems(r.sessions ?? []) + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + setErr('invalid response: session.list') + setLoading(false) + + return + } + + setItems((r?.sessions ?? []) as SessionItem[]) + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) setLoading(false) }) - .catch(() => setLoading(false)) }, [gw]) useInput((ch, key) => { @@ -81,6 +96,15 @@ export function SessionPicker({ return loading sessions… } + if (err) { + return ( + + error: {err} + Esc to cancel + + ) + } + if (!items.length) { return ( diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts new file mode 100644 index 0000000000..502aab8fbf --- /dev/null +++ b/ui-tui/src/lib/rpc.ts @@ -0,0 +1,21 @@ +export type RpcResult = Record + +export const asRpcResult = (value: unknown): RpcResult | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + + return value as RpcResult +} + +export const rpcErrorMessage = (err: unknown) => { + if (err instanceof Error && err.message) { + return err.message + } + + if (typeof err === 'string' && err.trim()) { + return err + } + + return 'request failed' +} From 77b97b810a5692656faad1e6d84138ad0a558145 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 14:49:10 -0500 Subject: [PATCH 093/157] chore: update how txt pasting ux feels --- hermes_constants.py | 3 +- tui_gateway/server.py | 58 +++++++++++- ui-tui/src/__tests__/text.test.ts | 51 +++++++++- ui-tui/src/app.tsx | 134 +++++++++++++-------------- ui-tui/src/components/pasteShelf.tsx | 7 +- ui-tui/src/gatewayClient.ts | 19 +++- ui-tui/src/lib/text.ts | 49 +++++++++- ui-tui/src/types.ts | 1 + 8 files changed, 239 insertions(+), 83 deletions(-) diff --git a/hermes_constants.py b/hermes_constants.py index 40b4da5693..4c2b95b42a 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -14,7 +14,8 @@ def get_hermes_home() -> Path: Reads HERMES_HOME env var, falls back to ~/.hermes. This is the single source of truth — all other copies should import this. """ - return Path(os.getenv("HERMES_HOME", Path.home() / ".hermes")) + val = os.environ.get("HERMES_HOME", "").strip() + return Path(val) if val else Path.home() / ".hermes" def get_default_hermes_root() -> Path: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f5b3ad73ac..e74313178e 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -134,6 +134,32 @@ def _status_update(sid: str, kind: str, text: str | None = None): _emit("status.update", sid, {"kind": kind if text is not None else "status", "text": body}) +def _estimate_image_tokens(width: int, height: int) -> int: + """Very rough UI estimate for image prompt cost. + + Uses 512px tiles at ~85 tokens/tile as a lightweight cross-provider hint. + This is intentionally approximate and only used for attachment display. + """ + if width <= 0 or height <= 0: + return 0 + return max(1, (width + 511) // 512) * max(1, (height + 511) // 512) * 85 + + +def _image_meta(path: Path) -> dict: + meta = {"name": path.name} + try: + from PIL import Image + + with Image.open(path) as img: + width, height = img.size + meta["width"] = int(width) + meta["height"] = int(height) + meta["token_estimate"] = _estimate_image_tokens(int(width), int(height)) + except Exception: + pass + return meta + + def _ok(rid, result: dict) -> dict: return {"jsonrpc": "2.0", "id": rid, "result": result} @@ -393,6 +419,18 @@ def _get_usage(agent) -> dict: return usage +def _probe_credentials(agent) -> str: + """Light credential check at session creation — returns warning or ''.""" + try: + key = getattr(agent, "api_key", "") or "" + provider = getattr(agent, "provider", "") or "" + if not key or key == "no-key-required": + return f"No API key configured for provider '{provider}'. First message will fail." + except Exception: + pass + return "" + + def _session_info(agent) -> dict: info: dict = { "model": getattr(agent, "model", ""), @@ -712,7 +750,11 @@ def _(rid, params: dict) -> dict: _init_session(sid, key, agent, [], cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"agent init failed: {e}") - return _ok(rid, {"session_id": sid, "info": _session_info(agent)}) + info = _session_info(agent) + warn = _probe_credentials(agent) + if warn: + info["credential_warning"] = warn + return _ok(rid, {"session_id": sid, "info": info}) @method("session.list") @@ -1049,7 +1091,15 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"attached": False, "message": msg}) session.setdefault("attached_images", []).append(str(img_path)) - return _ok(rid, {"attached": True, "path": str(img_path), "count": len(session["attached_images"])}) + return _ok( + rid, + { + "attached": True, + "path": str(img_path), + "count": len(session["attached_images"]), + **_image_meta(img_path), + }, + ) @method("image.attach") @@ -1075,10 +1125,10 @@ def _(rid, params: dict) -> dict: { "attached": True, "path": str(image_path), - "name": image_path.name, "count": len(session["attached_images"]), "remainder": remainder, "text": remainder or f"[User attached image: {image_path.name}]", + **_image_meta(image_path), }, ) except Exception as e: @@ -1109,9 +1159,9 @@ def _(rid, params: dict) -> dict: "matched": True, "is_image": True, "path": str(drop_path), - "name": drop_path.name, "count": len(session["attached_images"]), "text": text, + **_image_meta(drop_path), }, ) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index d43f6d56f4..904e44ec2a 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest' -import { estimateRows, fmtK, isToolTrailResultLine, lastCotTrailIndex, sameToolTrailGroup } from '../lib/text.js' +import { + edgePreview, + estimateRows, + estimateTokensRough, + fmtK, + isToolTrailResultLine, + lastCotTrailIndex, + pasteTokenLabel, + sameToolTrailGroup +} from '../lib/text.js' describe('isToolTrailResultLine', () => { it('detects completion markers', () => { @@ -50,6 +59,46 @@ describe('fmtK', () => { }) }) +describe('estimateTokensRough', () => { + it('uses 4 chars per token rounding up', () => { + expect(estimateTokensRough('')).toBe(0) + expect(estimateTokensRough('a')).toBe(1) + expect(estimateTokensRough('abcd')).toBe(1) + expect(estimateTokensRough('abcde')).toBe(2) + }) +}) + +describe('edgePreview', () => { + it('keeps both ends for long text', () => { + expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe( + 'Vampire.. stained with blood' + ) + }) +}) + +describe('pasteTokenLabel', () => { + it('builds readable long-paste labels with counts', () => { + expect( + pasteTokenLabel({ + charCount: 1000, + id: 7, + lineCount: 250, + text: 'Vampire Bondage ropes slipped from her neck, still stained with blood', + tokenCount: 250 + }) + ).toContain('[[paste:7 ') + expect( + pasteTokenLabel({ + charCount: 1000, + id: 7, + lineCount: 250, + text: 'Vampire Bondage ropes slipped from her neck, still stained with blood', + tokenCount: 250 + }) + ).toContain('[250 lines · 250 tok · 1K chars]') + }) +}) + describe('estimateRows', () => { it('handles tilde code fences', () => { const md = ['~~~markdown', '# heading', '~~~'].join('\n') diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index fdb7f24e55..80a4ea4a4f 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -24,10 +24,12 @@ import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' import { buildToolTrailLine, compactPreview, + estimateTokensRough, fmtK, hasInterpolation, isToolTrailResultLine, isTransientTrailLine, + pasteTokenLabel, pick, sameToolTrailGroup, stripTrailingPasteNewlines, @@ -54,7 +56,7 @@ import type { // ── Constants ──────────────────────────────────────────────────────── const PLACEHOLDER = pick(PLACEHOLDERS) -const PASTE_TOKEN_RE = /\[\[paste:(\d+)\]\]/g +const PASTE_TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() const LARGE_PASTE = { chars: 8000, lines: 80 } @@ -109,14 +111,20 @@ const redactSecrets = (text: string) => { return { redactions, text: cleaned } } -const pasteToken = (id: number) => `[[paste:${id}]]` - const stripTokens = (text: string, re: RegExp) => text .replace(re, '') .replace(/\s{2,}/g, ' ') .trim() +const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { + const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' + const tok = + typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' + + return [dims, tok].filter(Boolean).join(' · ') +} + const toTranscriptMessages = (rows: unknown): Msg[] => { if (!Array.isArray(rows)) { return [] @@ -345,7 +353,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [pastes, setPastes] = useState([]) - const [pasteReview, setPasteReview] = useState<{ largeIds: number[]; text: string } | null>(null) const [streaming, setStreaming] = useState('') const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) @@ -425,7 +432,7 @@ export function App({ gw }: { gw: GatewayClient }) { ) function blocked() { - return !!(clarify || approval || pasteReview || picker || secret || sudo || pager) + return !!(clarify || approval || picker || secret || sudo || pager) } const empty = !messages.length @@ -617,7 +624,6 @@ export function App({ gw }: { gw: GatewayClient }) { setBusy(false) setClarify(null) setApproval(null) - setPasteReview(null) setSudo(null) setSecret(null) setStreaming('') @@ -632,7 +638,6 @@ export function App({ gw }: { gw: GatewayClient }) { const clearIn = () => { setInput('') setInputBuf([]) - setPasteReview(null) setQueueEdit(null) setHistoryIdx(null) historyDraftRef.current = '' @@ -661,6 +666,8 @@ export function App({ gw }: { gw: GatewayClient }) { (msg?: string) => rpc('session.create', { cols: colsRef.current }).then((r: any) => { if (!r) { + setStatus('ready') + return } @@ -681,6 +688,10 @@ export function App({ gw }: { gw: GatewayClient }) { setInfo(null) } + if (r.info?.credential_warning) { + sys(`warning: ${r.info.credential_warning}`) + } + if (msg) { sys(msg) } @@ -727,20 +738,6 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Paste pipeline ─────────────────────────────────────────────── - const listPasteIds = useCallback((text: string) => { - const ids = new Set() - - for (const m of text.matchAll(PASTE_TOKEN_RE)) { - const id = parseInt(m[1] ?? '-1', 10) - - if (id > 0) { - ids.add(id) - } - } - - return [...ids] - }, []) - const resolvePasteTokens = useCallback( (text: string) => { const byId = new Map(pastes.map(p => [p.id, p])) @@ -792,11 +789,20 @@ export function App({ gw }: { gw: GatewayClient }) { const paste = useCallback( (quiet = false) => - rpc('clipboard.paste', { session_id: sid }).then((r: any) => - r?.attached - ? sys(`📎 Image #${r.count} attached from clipboard`) - : quiet || sys(r?.message || 'No image found in clipboard') - ), + rpc('clipboard.paste', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (r.attached) { + const meta = imageTokenMeta(r) + sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) + + return + } + + quiet || sys(r.message || 'No image found in clipboard') + }), [rpc, sid, sys] ) @@ -830,7 +836,9 @@ export function App({ gw }: { gw: GatewayClient }) { pasteCounterRef.current++ const id = pasteCounterRef.current const mode: PasteMode = 'attach' - const token = pasteToken(id) + const charCount = cleanedText.length + const tokenCount = estimateTokensRough(cleanedText) + const token = pasteTokenLabel({ charCount, id, lineCount, text: cleanedText, tokenCount }) const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' const insert = `${lead}${token}${tail}` @@ -839,18 +847,19 @@ export function App({ gw }: { gw: GatewayClient }) { [ ...prev, { - charCount: cleanedText.length, + charCount, createdAt: Date.now(), id, kind: classifyPaste(cleanedText), lineCount, mode, - text: cleanedText + text: cleanedText, + tokenCount } ].slice(-24) ) - pushActivity(`captured ${lineCount}L paste as ${token} (${mode})`) + pushActivity(`captured ${lineCount} lines · ${fmtK(tokenCount)} tok as #${id} (${mode})`) return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, @@ -898,7 +907,8 @@ export function App({ gw }: { gw: GatewayClient }) { .then((r: any) => { if (r?.matched) { if (r.is_image) { - pushActivity(`attached image: ${r.name}`) + const meta = imageTokenMeta(r) + pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) } else { pushActivity(`detected file: ${r.name}`) } @@ -1017,7 +1027,7 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Dispatch ───────────────────────────────────────────────────── const dispatchSubmission = useCallback( - (full: string, allowLarge = false) => { + (full: string) => { if (!full.trim() || !sid) { return } @@ -1053,19 +1063,6 @@ export function App({ gw }: { gw: GatewayClient }) { return } - const largeIds = listPasteIds(full).filter(id => { - const p = pastes.find(x => x.id === id) - - return !!p && (p.charCount >= LARGE_PASTE.chars || p.lineCount >= LARGE_PASTE.lines) - }) - - if (!allowLarge && largeIds.length) { - setPasteReview({ largeIds, text: full }) - setStatus(`review large paste (${largeIds.length})`) - - return - } - clearInput() const editIdx = queueEditRef.current @@ -1108,7 +1105,7 @@ export function App({ gw }: { gw: GatewayClient }) { send(full) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [appendMessage, busy, enqueue, gw, listPasteIds, pastes, pushHistory, resolvePasteTokens, sid] + [appendMessage, busy, enqueue, gw, pushHistory, resolvePasteTokens, sid] ) // ── Input handling ─────────────────────────────────────────────── @@ -1135,18 +1132,6 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (pasteReview) { - if (key.return) { - setPasteReview(null) - dispatchSubmission(pasteReview.text, true) - } else if (key.escape || ctrl(key, ch, 'c')) { - setPasteReview(null) - setStatus('ready') - } - - return - } - if (ctrl(key, ch, 'c')) { if (clarify) { answerClarify('') @@ -1450,6 +1435,13 @@ export function App({ gw }: { gw: GatewayClient }) { break + case 'gateway.stderr': + if (p?.line) { + pushActivity(String(p.line).slice(0, 120), 'error') + } + + break + case 'gateway.protocol_error': setStatus('protocol warning') @@ -1648,12 +1640,18 @@ export function App({ gw }: { gw: GatewayClient }) { case 'error': inflightPasteIdsRef.current = [] - sys(`error: ${p?.message}`) idle() setReasoning('') - setActivity([]) turnToolsRef.current = [] persistedToolLabelsRef.current.clear() + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + statusTimerRef.current = null + } + + pushActivity(String(p?.message || 'unknown error'), 'error') + sys(`error: ${p?.message}`) setStatus('ready') break @@ -1687,6 +1685,7 @@ export function App({ gw }: { gw: GatewayClient }) { gw.on('event', handler) gw.on('exit', exitHandler) + gw.drain() return () => { gw.off('event', handler) @@ -2002,7 +2001,8 @@ export function App({ gw }: { gw: GatewayClient }) { return } - sys(`attached image: ${r.name}`) + const meta = imageTokenMeta(r) + sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) if (r?.remainder) { setInput(r.remainder) @@ -2650,7 +2650,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (next && sid) { setQueueEdit(null) - dispatchSubmission(next, true) + dispatchSubmission(next) } } @@ -2733,16 +2733,6 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {pasteReview && ( - - - Review large paste before send - - pastes: {pasteReview.largeIds.map(id => `#${id}`).join(', ')} - Enter to send · Esc/Ctrl+C to cancel - - )} - {clarify && ( Paste shelf ({pastes.length}) {pastes.slice(-4).map(paste => ( - #{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {paste.kind} + #{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {fmtK(paste.tokenCount)} tok ·{' '} + {fmtK(paste.charCount)} chars · {paste.kind} {inDraft.has(paste.id) ? · in draft : ''} {' · '} {compactPreview(paste.text, 44) || '(empty)'} diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index fb26d9b5e3..40bd77763c 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -22,6 +22,8 @@ export class GatewayClient extends EventEmitter { private reqId = 0 private logs: string[] = [] private pending = new Map() + private bufferedEvents: GatewayEvent[] = [] + private subscribed = false start() { const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') @@ -76,7 +78,13 @@ export class GatewayClient extends EventEmitter { } if (msg.method === 'event') { - this.emit('event', msg.params as GatewayEvent) + const ev = msg.params as GatewayEvent + + if (this.subscribed) { + this.emit('event', ev) + } else { + this.bufferedEvents.push(ev) + } } } @@ -95,6 +103,15 @@ export class GatewayClient extends EventEmitter { } } + drain() { + this.subscribed = true + const pending = this.bufferedEvents.splice(0) + + for (const ev of pending) { + this.emit('event', ev) + } + } + getLogTail(limit = 20): string { return this.logs.slice(-Math.max(1, limit)).join('\n') } diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index b38b8fbd27..1841bdd770 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -43,6 +43,53 @@ export const compactPreview = (s: string, max: number) => { return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } +export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2) + +export const edgePreview = (s: string, head = 16, tail = 28) => { + const one = s.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]') + + if (!one) { + return '' + } + + if (one.length <= head + tail + 4) { + return one + } + + return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` +} + +export const pasteTokenLabel = ({ + charCount, + id, + lineCount, + text, + tokenCount +}: { + charCount: number + id: number + lineCount: number + text: string + tokenCount: number +}) => { + const preview = edgePreview(text) + const counts = `[${fmtK(lineCount)} lines · ${fmtK(tokenCount)} tok · ${fmtK(charCount)} chars]` + + if (!preview) { + return `[[paste:${id} ${counts}]]` + } + + const one = text.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]') + + if (one.length === preview.length) { + return `[[paste:${id} ${preview} ${counts}]]` + } + + const [head = preview, tail = ''] = preview.split('.. ', 2) + + return `[[paste:${id} ${head.trimEnd()}.. ${counts} .. ${tail.trimStart()}]]` +} + export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => { const text = reasoning.replace(/\n/g, ' ').trim() @@ -196,4 +243,4 @@ export const userDisplay = (text: string): string => { } export const isPasteBackedText = (text: string): boolean => - /\[\[paste:\d+\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text) + /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 8f24ba7ac5..784b69015e 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -88,6 +88,7 @@ export interface PendingPaste { lineCount: number mode: PasteMode text: string + tokenCount: number } export interface SlashCatalog { From ebe3270430f6f8babedd7559d77f8fad52b3fc11 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 14:57:42 -0500 Subject: [PATCH 094/157] fix: fake models --- hermes_cli/model_switch.py | 8 ++--- hermes_cli/models.py | 39 ++++++++++------------- tests/hermes_cli/test_model_validation.py | 11 ++++--- tests/test_tui_gateway_server.py | 3 +- tui_gateway/server.py | 11 ++++--- ui-tui/src/app.tsx | 6 ++++ 6 files changed, 40 insertions(+), 38 deletions(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 988983dbad..a257de48b7 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -660,12 +660,12 @@ def switch_model( api_key=api_key, base_url=base_url, ) - except Exception: + except Exception as e: validation = { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, - "message": None, + "message": f"Could not validate `{new_model}`: {e}", } if not validation.get("accepted"): diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 8577769832..18b62bb489 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1842,12 +1842,11 @@ def validate_requested_model( if suggestions: suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": ( - f"Note: `{requested}` was not found in the OpenAI Codex model listing. " - f"It may still work if your account has access to it." + f"Model `{requested}` was not found in the OpenAI Codex model listing." f"{suggestion_text}" ), } @@ -1864,26 +1863,20 @@ def validate_requested_model( "recognized": True, "message": None, } - else: - # API responded but model is not listed. Accept anyway — - # the user may have access to models not shown in the public - # listing (e.g. Z.AI Pro/Max plans can use glm-5 on coding - # endpoints even though it's not in /models). Warn but allow. - suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5) - suggestion_text = "" - if suggestions: - suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) + suggestions = get_close_matches(requested, api_models, n=3, cutoff=0.5) + suggestion_text = "" + if suggestions: + suggestion_text = "\n Similar models: " + ", ".join(f"`{s}`" for s in suggestions) - return { - "accepted": True, - "persist": True, - "recognized": False, - "message": ( - f"Note: `{requested}` was not found in this provider's model listing. " - f"It may still work if your plan supports it." - f"{suggestion_text}" - ), - } + return { + "accepted": False, + "persist": False, + "recognized": False, + "message": ( + f"Model `{requested}` was not found in this provider's model listing." + f"{suggestion_text}" + ), + } # api_models is None — couldn't reach API. Accept and persist, # but warn so typos don't silently break things. diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index af1d89ae8d..be08ca034f 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -401,7 +401,8 @@ class TestValidateFormatChecks: def test_no_slash_model_rejected_if_not_in_api(self): result = _validate("gpt-5.4", api_models=["openai/gpt-5.4"]) - assert result["accepted"] is True + assert result["accepted"] is False + assert result["persist"] is False assert "not found" in result["message"] @@ -427,15 +428,15 @@ class TestValidateApiFound: # -- validate — API not found ------------------------------------------------ class TestValidateApiNotFound: - def test_model_not_in_api_accepted_with_warning(self): + def test_model_not_in_api_rejected_with_guidance(self): result = _validate("anthropic/claude-nonexistent") - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False assert "not found" in result["message"] def test_warning_includes_suggestions(self): result = _validate("anthropic/claude-opus-4.5") - assert result["accepted"] is True + assert result["accepted"] is False assert "Similar models" in result["message"] diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 137a5de084..9ef4398e95 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -155,7 +155,7 @@ def test_config_set_model_uses_live_switch_path(monkeypatch): def _fake_apply(sid, session, raw): seen["args"] = (sid, session["session_key"], raw) - return "new/model" + return {"value": "new/model", "warning": "catalog unreachable"} monkeypatch.setattr(server, "_apply_model_switch", _fake_apply) resp = server.handle_request( @@ -163,6 +163,7 @@ def test_config_set_model_uses_live_switch_path(monkeypatch): ) assert resp["result"]["value"] == "new/model" + assert resp["result"]["warning"] == "catalog unreachable" assert seen["args"] == ("sid", "session-key", "new/model") diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e74313178e..f90b881e8a 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -327,11 +327,11 @@ def _restart_slash_worker(session: dict): session["slash_worker"] = None -def _apply_model_switch(sid: str, session: dict, raw_input: str) -> str: +def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: agent = session.get("agent") if not agent: os.environ["HERMES_MODEL"] = raw_input - return raw_input + return {"value": raw_input, "warning": ""} from hermes_cli.model_switch import switch_model @@ -355,7 +355,7 @@ def _apply_model_switch(sid: str, session: dict, raw_input: str) -> str: os.environ["HERMES_MODEL"] = result.new_model _restart_slash_worker(session) _emit("session.info", sid, _session_info(agent)) - return result.new_model + return {"value": result.new_model, "warning": result.warning_message or ""} def _compress_session_history(session: dict) -> tuple[int, dict]: @@ -1273,10 +1273,11 @@ def _(rid, params: dict) -> dict: if not value: return _err(rid, 4002, "model value required") if session: - value = _apply_model_switch(params.get("session_id", ""), session, value) + result = _apply_model_switch(params.get("session_id", ""), session, value) else: os.environ["HERMES_MODEL"] = value - return _ok(rid, {"key": key, "value": value}) + result = {"value": value, "warning": ""} + return _ok(rid, {"key": key, "value": result["value"], "warning": result["warning"]}) except Exception as e: return _err(rid, 5001, str(e)) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 80a4ea4a4f..e9152f1b3b 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -119,6 +119,7 @@ const stripTokens = (text: string, re: RegExp) => const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' + const tok = typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' @@ -1988,6 +1989,11 @@ export function App({ gw }: { gw: GatewayClient }) { } sys(`model → ${r.value}`) + + if (r.warning) { + sys(`warning: ${r.warning}`) + } + setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) } ) From 4a260b51fedc4dd244f242eea3ed00f5cd78404d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 15:01:15 -0500 Subject: [PATCH 095/157] fix: deep markdown parsing --- ui-tui/src/components/markdown.tsx | 35 ++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 6a59227734..3ba0114abf 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -81,15 +81,42 @@ const isTableDivider = (row: string) => { return cells.length > 1 && cells.every(cell => TABLE_DIVIDER_CELL_RE.test(cell)) } +const stripInlineMarkup = (value: string) => + value + .replace(/!\[(.*?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '[image: $1] $2') + .replace(/\[(.+?)\]\(((?:[^\s()]|\([^\s()]*\))+?)\)/g, '$1') + .replace(/<((?:https?:\/\/|mailto:)[^>\s]+|[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})>/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/==(.+?)==/g, '$1') + .replace(/\[\^([^\]]+)\]/g, '[$1]') + .replace(/\^([^^\s][^^]*?)\^/g, '^$1') + .replace(/~([^~\s][^~]*?)~/g, '_$1') + const renderTable = (key: number, rows: string[][], t: Theme) => { - const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => (r[ci] ?? '').length))) + const widths = rows[0]!.map((_, ci) => Math.max(...rows.map(r => stripInlineMarkup(r[ci] ?? '').length))) return ( {rows.map((row, ri) => ( - - {row.map((cell, ci) => cell.padEnd(widths[ci] ?? 0)).join(' ')} - + + {widths.map((width, ci) => { + const cell = row[ci] ?? '' + const pad = ' '.repeat(Math.max(0, width - stripInlineMarkup(cell).length)) + + return ( + + + {pad} + {ci < widths.length - 1 ? ' ' : ''} + + ) + })} + ))} ) From 783c6b6ed6d05755051e0beda26cfd5a49377901 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 15:08:06 -0500 Subject: [PATCH 096/157] chore: uptick --- ui-tui/src/components/queuedMessages.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index 2bf578eb41..c7ae29e24d 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -21,7 +21,7 @@ export function estimateQueuedRows(queueLen: number, queueEditIdx: number | null const win = getQueueWindow(queueLen, queueEditIdx) - return 1 + (win.showLead ? 1 : 0) + (win.end - win.start) + (win.showTail ? 1 : 0) + return 1 + 1 + (win.showLead ? 1 : 0) + (win.end - win.start) + (win.showTail ? 1 : 0) } export function QueuedMessages({ @@ -42,7 +42,7 @@ export function QueuedMessages({ const q = getQueueWindow(queued.length, queueEditIdx) return ( - + queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} From aeb53131f3e09a7006d890cc4f78d5e729dbcc55 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 18:29:24 -0500 Subject: [PATCH 097/157] fix(ui-tui): harden TUI error handling, model validation, command UX parity, and gateway lifecycle --- cli.py | 6 + hermes_cli/commands.py | 6 +- hermes_cli/models.py | 12 +- tests/hermes_cli/test_model_validation.py | 32 +- tests/test_tui_gateway_server.py | 108 +++- tui_gateway/server.py | 386 +++++++++++--- ui-tui/.gitignore | 1 + ui-tui/src/app.tsx | 619 ++++++++++++++++------ ui-tui/src/components/modelPicker.tsx | 241 +++++++++ ui-tui/src/components/textInput.tsx | 82 ++- ui-tui/src/constants.ts | 2 +- ui-tui/src/gatewayClient.ts | 99 +++- ui-tui/src/hooks/useCompletion.ts | 15 +- ui-tui/src/lib/text.ts | 2 +- ui-tui/src/types.ts | 1 + 15 files changed, 1303 insertions(+), 309 deletions(-) create mode 100644 ui-tui/src/components/modelPicker.tsx diff --git a/cli.py b/cli.py index efeccf5cf5..4312a6b542 100644 --- a/cli.py +++ b/cli.py @@ -1194,6 +1194,10 @@ def _resolve_attachment_path(raw_path: str) -> Path | None: return None expanded = os.path.expandvars(os.path.expanduser(token)) + if os.name != "nt": + normalized = expanded.replace("\\", "/") + if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha(): + expanded = f"/mnt/{normalized[0].lower()}/{normalized[3:]}" path = Path(expanded) if not path.is_absolute(): base_dir = Path(os.getenv("TERMINAL_CWD", os.getcwd())) @@ -1276,10 +1280,12 @@ def _detect_file_drop(user_input: str) -> "dict | None": or stripped.startswith("~") or stripped.startswith("./") or stripped.startswith("../") + or (len(stripped) >= 3 and stripped[1] == ":" and stripped[2] in ("\\", "/") and stripped[0].isalpha()) or stripped.startswith('"/') or stripped.startswith('"~') or stripped.startswith("'/") or stripped.startswith("'~") + or (len(stripped) >= 4 and stripped[0] in ("'", '"') and stripped[2] == ":" and stripped[3] in ("\\", "/") and stripped[1].isalpha()) ) if not starts_like_path: return None diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index ebd13f54b9..3d1f370352 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -73,7 +73,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ args_hint="[focus topic]"), CommandDef("rollback", "List or restore filesystem checkpoints", "Session", args_hint="[number]"), - CommandDef("stop", "Kill all running background processes", "Session"), + CommandDef("stop", "Kill all running registered subprocesses", "Session"), CommandDef("approve", "Approve a pending dangerous command", "Session", gateway_only=True, args_hint="[session|always]"), CommandDef("deny", "Deny a pending dangerous command", "Session", @@ -96,7 +96,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ # Configuration CommandDef("config", "Show current configuration", "Configuration", cli_only=True), - CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--global]"), + CommandDef("model", "Switch model for this session", "Configuration", args_hint="[model] [--provider name] [--global]"), CommandDef("provider", "Show available providers and current provider", "Configuration"), @@ -152,7 +152,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ cli_only=True, aliases=("gateway",)), CommandDef("copy", "Copy the last assistant response to clipboard", "Info", cli_only=True, args_hint="[number]"), - CommandDef("paste", "Check clipboard for an image and attach it", "Info", + CommandDef("paste", "Attach clipboard image or manage text paste shelf", "Info", cli_only=True), CommandDef("image", "Attach a local image file for your next prompt", "Info", cli_only=True, args_hint=""), diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 18b62bb489..964e1b5227 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -1803,8 +1803,8 @@ def validate_requested_model( ) return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": message, } @@ -1817,8 +1817,8 @@ def validate_requested_model( message += f"\n If this server expects `/v1`, try base URL: `{probe.get('suggested_base_url')}`" return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": message, } @@ -1882,8 +1882,8 @@ def validate_requested_model( # but warn so typos don't silently break things. provider_label = _PROVIDER_LABELS.get(normalized, normalized) return { - "accepted": True, - "persist": True, + "accepted": False, + "persist": False, "recognized": False, "message": ( f"Could not reach the {provider_label} API to validate `{requested}`. " diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index be08ca034f..3b83b81da8 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -437,33 +437,33 @@ class TestValidateApiNotFound: def test_warning_includes_suggestions(self): result = _validate("anthropic/claude-opus-4.5") assert result["accepted"] is False + assert result["persist"] is False assert "Similar models" in result["message"] -# -- validate — API unreachable — accept and persist everything ---------------- +# -- validate — API unreachable — reject with guidance ---------------- class TestValidateApiFallback: - def test_any_model_accepted_when_api_down(self): + def test_any_model_rejected_when_api_down(self): result = _validate("anthropic/claude-opus-4.6", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False - def test_unknown_model_also_accepted_when_api_down(self): - """No hardcoded catalog gatekeeping — accept, persist, and warn.""" + def test_unknown_model_also_rejected_when_api_down(self): result = _validate("anthropic/claude-next-gen", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False assert "could not reach" in result["message"].lower() - def test_zai_model_accepted_when_api_down(self): + def test_zai_model_rejected_when_api_down(self): result = _validate("glm-5", provider="zai", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False - def test_unknown_provider_accepted_when_api_down(self): + def test_unknown_provider_rejected_when_api_down(self): result = _validate("some-model", provider="totally-unknown", api_models=None) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False def test_custom_endpoint_warns_with_probed_url_and_v1_hint(self): with patch( @@ -483,7 +483,7 @@ class TestValidateApiFallback: base_url="http://localhost:8000", ) - assert result["accepted"] is True - assert result["persist"] is True + assert result["accepted"] is False + assert result["persist"] is False assert "http://localhost:8000/v1/models" in result["message"] assert "http://localhost:8000/v1" in result["message"] diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 9ef4398e95..bee0d88117 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -167,11 +167,87 @@ def test_config_set_model_uses_live_switch_path(monkeypatch): assert seen["args"] == ("sid", "session-key", "new/model") +def test_config_set_model_global_persists(monkeypatch): + class _Agent: + provider = "openrouter" + model = "old/model" + base_url = "" + api_key = "sk-old" + + def switch_model(self, **kwargs): + return None + + result = types.SimpleNamespace( + success=True, + new_model="anthropic/claude-sonnet-4.6", + target_provider="anthropic", + api_key="sk-new", + base_url="https://api.anthropic.com", + api_mode="anthropic_messages", + warning_message="", + ) + seen = {} + saved = {} + + def _switch_model(**kwargs): + seen.update(kwargs) + return result + + server._sessions["sid"] = _session(agent=_Agent()) + monkeypatch.setattr("hermes_cli.model_switch.switch_model", _switch_model) + monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) + monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None) + monkeypatch.setattr("hermes_cli.config.save_config", lambda cfg: saved.update(cfg)) + + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "model", "value": "anthropic/claude-sonnet-4.6 --global"}} + ) + + assert resp["result"]["value"] == "anthropic/claude-sonnet-4.6" + assert seen["is_global"] is True + assert saved["model"]["default"] == "anthropic/claude-sonnet-4.6" + assert saved["model"]["provider"] == "anthropic" + assert saved["model"]["base_url"] == "https://api.anthropic.com" + + +def test_config_set_personality_rejects_unknown_name(monkeypatch): + monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"key": "personality", "value": "bogus"}} + ) + + assert "error" in resp + assert "Unknown personality" in resp["error"]["message"] + + +def test_config_set_personality_resets_history_and_returns_info(monkeypatch): + session = _session(agent=types.SimpleNamespace(), history=[{"role": "user", "text": "hi"}], history_version=4) + new_agent = types.SimpleNamespace(model="x") + emits = [] + + server._sessions["sid"] = session + monkeypatch.setattr(server, "_available_personalities", lambda cfg=None: {"helpful": "You are helpful."}) + monkeypatch.setattr(server, "_make_agent", lambda sid, key, session_id=None: new_agent) + monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}) + monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) + monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) + + resp = server.handle_request( + {"id": "1", "method": "config.set", "params": {"session_id": "sid", "key": "personality", "value": "helpful"}} + ) + + assert resp["result"]["history_reset"] is True + assert resp["result"]["info"] == {"model": "x"} + assert session["history"] == [] + assert session["history_version"] == 5 + assert ("session.info", "sid", {"model": "x"}) in emits + + def test_session_compress_uses_compress_helper(monkeypatch): agent = types.SimpleNamespace() server._sessions["sid"] = _session(agent=agent) - monkeypatch.setattr(server, "_compress_session_history", lambda session: (2, {"total": 42})) + monkeypatch.setattr(server, "_compress_session_history", lambda session, focus_topic=None: (2, {"total": 42})) monkeypatch.setattr(server, "_session_info", lambda _agent: {"model": "x"}) with patch("tui_gateway.server._emit") as emit: @@ -266,6 +342,36 @@ def test_image_attach_appends_local_image(monkeypatch): assert len(server._sessions["sid"]["attached_images"]) == 1 +def test_command_dispatch_exec_nonzero_surfaces_error(monkeypatch): + monkeypatch.setattr(server, "_load_cfg", lambda: {"quick_commands": {"boom": {"type": "exec", "command": "boom"}}}) + monkeypatch.setattr( + server.subprocess, + "run", + lambda *args, **kwargs: types.SimpleNamespace(returncode=1, stdout="", stderr="failed"), + ) + + resp = server.handle_request({"id": "1", "method": "command.dispatch", "params": {"name": "boom"}}) + + assert "error" in resp + assert "failed" in resp["error"]["message"] + + +def test_plugins_list_surfaces_loader_error(monkeypatch): + with patch("hermes_cli.plugins.get_plugin_manager", side_effect=Exception("boom")): + resp = server.handle_request({"id": "1", "method": "plugins.list", "params": {}}) + + assert "error" in resp + assert "boom" in resp["error"]["message"] + + +def test_complete_slash_surfaces_completer_error(monkeypatch): + with patch("hermes_cli.commands.SlashCommandCompleter", side_effect=Exception("no completer")): + resp = server.handle_request({"id": "1", "method": "complete.slash", "params": {"text": "/mo"}}) + + assert "error" in resp + assert "no completer" in resp["error"]["message"] + + def test_input_detect_drop_attaches_image(monkeypatch): fake_cli = types.ModuleType("cli") fake_cli._detect_file_drop = lambda raw: { diff --git a/tui_gateway/server.py b/tui_gateway/server.py index f90b881e8a..86f3617e29 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -183,10 +183,19 @@ def handle_request(req: dict) -> dict | None: def _sess(params, rid): - s = _sessions.get(params.get("session_id", "")) + s = _sessions.get(params.get("session_id") or "") return (s, None) if s else (None, _err(rid, 4001, "session not found")) +def _normalize_completion_path(path_part: str) -> str: + expanded = os.path.expanduser(path_part) + if os.name != "nt": + normalized = expanded.replace("\\", "/") + if len(normalized) >= 3 and normalized[1] == ":" and normalized[2] == "/" and normalized[0].isalpha(): + return f"/mnt/{normalized[0].lower()}/{normalized[3:]}" + return expanded + + # ── Config I/O ──────────────────────────────────────────────────────── def _load_cfg() -> dict: @@ -327,38 +336,75 @@ def _restart_slash_worker(session: dict): session["slash_worker"] = None -def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: - agent = session.get("agent") - if not agent: - os.environ["HERMES_MODEL"] = raw_input - return {"value": raw_input, "warning": ""} +def _persist_model_switch(result) -> None: + from hermes_cli.config import save_config - from hermes_cli.model_switch import switch_model + cfg = _load_cfg() + model_cfg = cfg.get("model") + if not isinstance(model_cfg, dict): + model_cfg = {} + cfg["model"] = model_cfg + + model_cfg["default"] = result.new_model + model_cfg["provider"] = result.target_provider + if result.base_url: + model_cfg["base_url"] = result.base_url + else: + model_cfg.pop("base_url", None) + save_config(cfg) + + +def _apply_model_switch(sid: str, session: dict, raw_input: str) -> dict: + from hermes_cli.model_switch import parse_model_flags, switch_model + from hermes_cli.runtime_provider import resolve_runtime_provider + + model_input, explicit_provider, persist_global = parse_model_flags(raw_input) + if not model_input: + raise ValueError("model value required") + + agent = session.get("agent") + if agent: + current_provider = getattr(agent, "provider", "") or "" + current_model = getattr(agent, "model", "") or "" + current_base_url = getattr(agent, "base_url", "") or "" + current_api_key = getattr(agent, "api_key", "") or "" + else: + runtime = resolve_runtime_provider(requested=None) + current_provider = str(runtime.get("provider", "") or "") + current_model = _resolve_model() + current_base_url = str(runtime.get("base_url", "") or "") + current_api_key = str(runtime.get("api_key", "") or "") result = switch_model( - raw_input=raw_input, - current_provider=getattr(agent, "provider", "") or "", - current_model=getattr(agent, "model", "") or "", - current_base_url=getattr(agent, "base_url", "") or "", - current_api_key=getattr(agent, "api_key", "") or "", + raw_input=model_input, + current_provider=current_provider, + current_model=current_model, + current_base_url=current_base_url, + current_api_key=current_api_key, + is_global=persist_global, + explicit_provider=explicit_provider, ) if not result.success: raise ValueError(result.error_message or "model switch failed") - agent.switch_model( - new_model=result.new_model, - new_provider=result.target_provider, - api_key=result.api_key, - base_url=result.base_url, - api_mode=result.api_mode, - ) + if agent: + agent.switch_model( + new_model=result.new_model, + new_provider=result.target_provider, + api_key=result.api_key, + base_url=result.base_url, + api_mode=result.api_mode, + ) + _restart_slash_worker(session) + _emit("session.info", sid, _session_info(agent)) + os.environ["HERMES_MODEL"] = result.new_model - _restart_slash_worker(session) - _emit("session.info", sid, _session_info(agent)) + if persist_global: + _persist_model_switch(result) return {"value": result.new_model, "warning": result.warning_message or ""} -def _compress_session_history(session: dict) -> tuple[int, dict]: +def _compress_session_history(session: dict, focus_topic: str | None = None) -> tuple[int, dict]: from agent.model_metadata import estimate_messages_tokens_rough agent = session["agent"] @@ -370,6 +416,7 @@ def _compress_session_history(session: dict) -> tuple[int, dict]: history, getattr(agent, "_cached_system_prompt", "") or "", approx_tokens=approx_tokens, + focus_topic=focus_topic or None, ) session["history"] = compressed session["history_version"] = int(session.get("history_version", 0)) + 1 @@ -617,21 +664,91 @@ def _resolve_personality_prompt(cfg: dict) -> str: if not name or name in ("default", "none", "neutral"): return "" try: - from hermes_cli.config import load_config as _load_full_cfg - personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) + from cli import load_cli_config + + personalities = load_cli_config().get("agent", {}).get("personalities", {}) except Exception: - personalities = cfg.get("agent", {}).get("personalities", {}) + try: + from hermes_cli.config import load_config as _load_full_cfg + + personalities = _load_full_cfg().get("agent", {}).get("personalities", {}) + except Exception: + personalities = cfg.get("agent", {}).get("personalities", {}) pval = personalities.get(name) if pval is None: return "" - if isinstance(pval, dict): - parts = [pval.get("system_prompt", "")] - if pval.get("tone"): - parts.append(f'Tone: {pval["tone"]}') - if pval.get("style"): - parts.append(f'Style: {pval["style"]}') + return _render_personality_prompt(pval) + + +def _render_personality_prompt(value) -> str: + if isinstance(value, dict): + parts = [value.get("system_prompt", "")] + if value.get("tone"): + parts.append(f'Tone: {value["tone"]}') + if value.get("style"): + parts.append(f'Style: {value["style"]}') return "\n".join(p for p in parts if p) - return str(pval) + return str(value) + + +def _available_personalities(cfg: dict | None = None) -> dict: + try: + from cli import load_cli_config + + return load_cli_config().get("agent", {}).get("personalities", {}) or {} + except Exception: + try: + from hermes_cli.config import load_config as _load_full_cfg + + return _load_full_cfg().get("agent", {}).get("personalities", {}) or {} + except Exception: + cfg = cfg or _load_cfg() + return cfg.get("agent", {}).get("personalities", {}) or {} + + +def _validate_personality(value: str, cfg: dict | None = None) -> tuple[str, str]: + raw = str(value or "").strip() + name = raw.lower() + if not name or name in ("none", "default", "neutral"): + return "", "" + + personalities = _available_personalities(cfg) + if name not in personalities: + names = sorted(personalities) + available = ", ".join(f"`{n}`" for n in names) + base = f"Unknown personality: `{raw}`." + if available: + base += f"\n\nAvailable: `none`, {available}" + else: + base += "\n\nNo personalities configured." + raise ValueError(base) + + return name, _render_personality_prompt(personalities[name]) + + +def _apply_personality_to_session(sid: str, session: dict, new_prompt: str) -> tuple[bool, dict | None]: + if not session: + return False, None + + try: + new_agent = _make_agent(sid, session["session_key"], session_id=session["session_key"]) + session["agent"] = new_agent + with session["history_lock"]: + session["history"] = [] + session["history_version"] = int(session.get("history_version", 0)) + 1 + info = _session_info(new_agent) + _emit("session.info", sid, info) + _restart_slash_worker(session) + return True, info + except Exception: + if session.get("agent"): + agent = session["agent"] + agent.ephemeral_system_prompt = new_prompt or None + agent._cached_system_prompt = None + info = _session_info(agent) + _emit("session.info", sid, info) + return False, info + return False, None def _make_agent(sid: str, key: str, session_id: str | None = None): @@ -893,9 +1010,11 @@ def _(rid, params: dict) -> dict: return err try: with session["history_lock"]: - removed, usage = _compress_session_history(session) - _emit("session.info", params.get("session_id", ""), _session_info(session["agent"])) - return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage}) + removed, usage = _compress_session_history(session, str(params.get("focus_topic", "") or "").strip()) + messages = list(session.get("history", [])) + info = _session_info(session["agent"]) + _emit("session.info", params.get("session_id", ""), info) + return _ok(rid, {"status": "compressed", "removed": removed, "usage": usage, "info": info, "messages": messages}) except Exception as e: return _err(rid, 5005, str(e)) @@ -906,7 +1025,7 @@ def _(rid, params: dict) -> dict: if err: return err import time as _time - filename = f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json" + filename = os.path.abspath(f"hermes_conversation_{_time.strftime('%Y%m%d_%H%M%S')}.json") try: with open(filename, "w") as f: json.dump({"model": getattr(session["agent"], "model", ""), "messages": session.get("history", [])}, @@ -916,6 +1035,27 @@ def _(rid, params: dict) -> dict: return _err(rid, 5011, str(e)) +@method("session.close") +def _(rid, params: dict) -> dict: + sid = params.get("session_id", "") + session = _sessions.pop(sid, None) + if not session: + return _ok(rid, {"closed": False}) + try: + from tools.approval import unregister_gateway_notify + + unregister_gateway_notify(session["session_key"]) + except Exception: + pass + try: + worker = session.get("slash_worker") + if worker: + worker.close() + except Exception: + pass + return _ok(rid, {"closed": True}) + + @method("session.branch") def _(rid, params: dict) -> dict: session, err = _sess(params, rid) @@ -1087,6 +1227,7 @@ def _(rid, params: dict) -> dict: # Save-first: mirrors CLI keybinding path; more robust than has_image() precheck if not save_clipboard_image(img_path): + session["image_counter"] = max(0, session["image_counter"] - 1) msg = "Clipboard has image but extraction failed" if has_clipboard_image() else "No image found in clipboard" return _ok(rid, {"attached": False, "message": msg}) @@ -1182,6 +1323,9 @@ def _(rid, params: dict) -> dict: @method("prompt.background") def _(rid, params: dict) -> dict: + session, err = _sess(params, rid) + if err: + return err text, parent = params.get("text", ""), params.get("session_id", "") if not text: return _err(rid, 4012, "text required") @@ -1275,8 +1419,7 @@ def _(rid, params: dict) -> dict: if session: result = _apply_model_switch(params.get("session_id", ""), session, value) else: - os.environ["HERMES_MODEL"] = value - result = {"value": value, "warning": ""} + result = _apply_model_switch("", {"agent": None}, value) return _ok(rid, {"key": key, "value": result["value"], "warning": result["warning"]}) except Exception as e: return _err(rid, 5001, str(e)) @@ -1368,25 +1511,12 @@ def _(rid, params: dict) -> dict: nv = value _save_cfg(cfg) elif key == "personality": - pname = value if value not in ("none", "default", "neutral") else "" - _write_config_key("display.personality", pname) - cfg = _load_cfg() - new_prompt = _resolve_personality_prompt(cfg) - _write_config_key("agent.system_prompt", new_prompt) - nv = value sid_key = params.get("session_id", "") - if session: - try: - new_agent = _make_agent(sid_key, session["session_key"], session_id=session["session_key"]) - session["agent"] = new_agent - with session["history_lock"]: - session["history"] = [] - session["history_version"] = int(session.get("history_version", 0)) + 1 - except Exception: - if session.get("agent"): - agent = session["agent"] - agent.ephemeral_system_prompt = new_prompt or None - agent._cached_system_prompt = None + pname, new_prompt = _validate_personality(str(value or ""), cfg) + _write_config_key("display.personality", pname) + _write_config_key("agent.system_prompt", new_prompt) + nv = str(value or "default") + history_reset, info = _apply_personality_to_session(sid_key, session, new_prompt) else: _write_config_key(f"display.{key}", value) nv = value @@ -1394,7 +1524,9 @@ def _(rid, params: dict) -> dict: _emit("skin.changed", "", resolve_skin()) resp = {"key": key, "value": nv} if key == "personality": - resp["cleared"] = True + resp["history_reset"] = history_reset + if info is not None: + resp["info"] = info return _ok(rid, resp) except Exception as e: return _err(rid, 5001, str(e)) @@ -1425,6 +1557,11 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"value": _load_cfg().get("display", {}).get("skin", "default")}) if key == "personality": return _ok(rid, {"value": _load_cfg().get("display", {}).get("personality", "default")}) + if key == "reasoning": + cfg = _load_cfg() + effort = str(cfg.get("agent", {}).get("reasoning_effort", "medium") or "medium") + display = "show" if bool(cfg.get("display", {}).get("show_reasoning", False)) else "hide" + return _ok(rid, {"value": effort, "display": display}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: @@ -1510,14 +1647,15 @@ def _(rid, params: dict) -> dict: cat_map[cat].append([name, desc]) skill_count = 0 + warning = "" try: from agent.skill_commands import scan_skill_commands for k, info in sorted(scan_skill_commands().items()): d = str(info.get("description", "Skill")) all_pairs.append([k, d[:120] + ("…" if len(d) > 120 else "")]) skill_count += 1 - except Exception: - pass + except Exception as e: + warning = f"skill discovery unavailable: {e}" for cat in cat_order: categories.append({"name": cat, "pairs": cat_map[cat]}) @@ -1529,6 +1667,7 @@ def _(rid, params: dict) -> dict: "canon": canon, "categories": categories, "skill_count": skill_count, + "warning": warning, }) except Exception as e: return _err(rid, 5020, str(e)) @@ -1611,7 +1750,10 @@ def _(rid, params: dict) -> dict: qc = qcmds[name] if qc.get("type") == "exec": r = subprocess.run(qc.get("command", ""), shell=True, capture_output=True, text=True, timeout=30) - return _ok(rid, {"type": "exec", "output": (r.stdout or r.stderr)[:4000]}) + output = ((r.stdout or "") + ("\n" if r.stdout and r.stderr else "") + (r.stderr or "")).strip()[:4000] + if r.returncode != 0: + return _err(rid, 4018, output or f"quick command failed with exit code {r.returncode}") + return _ok(rid, {"type": "exec", "output": output}) if qc.get("type") == "alias": return _ok(rid, {"type": "alias", "target": qc.get("target", "")}) @@ -1692,15 +1834,18 @@ def _(rid, params: dict) -> dict: prefix_tag = "" path_part = query if not is_context else query - expanded = os.path.expanduser(path_part) + expanded = _normalize_completion_path(path_part) if expanded.endswith("/"): search_dir, match = expanded, "" else: search_dir = os.path.dirname(expanded) or "." match = os.path.basename(expanded) + if not os.path.isdir(search_dir): + return _ok(rid, {"items": []}) + match_lower = match.lower() - for entry in sorted(os.listdir(search_dir))[:200]: + for entry in sorted(os.listdir(search_dir)): if match and not entry.lower().startswith(match_lower): continue if is_context and not prefix_tag and entry.startswith("."): @@ -1725,8 +1870,8 @@ def _(rid, params: dict) -> dict: items.append({"text": text, "display": entry + suffix, "meta": "dir" if is_dir else ""}) if len(items) >= 30: break - except Exception: - pass + except Exception as e: + return _err(rid, 5021, str(e)) return _ok(rid, {"items": items}) @@ -1742,39 +1887,83 @@ def _(rid, params: dict) -> dict: from prompt_toolkit.document import Document from prompt_toolkit.formatted_text import to_plain_text - completer = SlashCommandCompleter() + from agent.skill_commands import get_skill_commands + + completer = SlashCommandCompleter(skill_commands_provider=lambda: get_skill_commands()) doc = Document(text, len(text)) items = [ {"text": c.text, "display": c.display or c.text, "meta": to_plain_text(c.display_meta) if c.display_meta else ""} for c in completer.get_completions(doc, None) ][:30] + text_lower = text.lower() + extras = [ + {"text": "/compact", "display": "/compact", "meta": "Toggle compact display mode"}, + {"text": "/logs", "display": "/logs", "meta": "Show recent gateway log lines"}, + ] + for extra in extras: + if extra["text"].startswith(text_lower) and not any(item["text"] == extra["text"] for item in items): + items.append(extra) return _ok(rid, {"items": items, "replace_from": text.rfind(" ") + 1 if " " in text else 1}) - except Exception: - return _ok(rid, {"items": []}) + except Exception as e: + return _err(rid, 5020, str(e)) + + +@method("model.options") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.model_switch import list_authenticated_providers + from hermes_cli.models import provider_model_ids + + session = _sessions.get(params.get("session_id", "")) + agent = session.get("agent") if session else None + cfg = _load_cfg() + current_provider = getattr(agent, "provider", "") or "" + current_model = getattr(agent, "model", "") or _resolve_model() + providers = list_authenticated_providers( + current_provider=current_provider, + user_providers=cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {}, + custom_providers=cfg.get("custom_providers") if isinstance(cfg.get("custom_providers"), list) else [], + max_models=50, + ) + for provider in providers: + try: + models = provider_model_ids(provider.get("slug")) + if models: + provider["models"] = models + provider["total_models"] = len(models) + except Exception as e: + provider["warning"] = f"model catalog unavailable: {e}" + return _ok(rid, {"providers": providers, "model": current_model, "provider": current_provider}) + except Exception as e: + return _err(rid, 5033, str(e)) # ── Methods: slash.exec ────────────────────────────────────────────── -def _mirror_slash_side_effects(sid: str, session: dict, command: str): +def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: """Apply side effects that must also hit the gateway's live agent.""" parts = command.lstrip("/").split(None, 1) if not parts: - return + return "" name, arg, agent = parts[0], (parts[1].strip() if len(parts) > 1 else ""), session.get("agent") try: if name == "model" and arg and agent: - _apply_model_switch(sid, session, arg) - elif name in ("personality", "prompt") and agent: + result = _apply_model_switch(sid, session, arg) + return result.get("warning", "") + elif name == "personality" and arg and agent: + _, new_prompt = _validate_personality(arg, _load_cfg()) + _apply_personality_to_session(sid, session, new_prompt) + elif name == "prompt" and agent: cfg = _load_cfg() new_prompt = cfg.get("agent", {}).get("system_prompt", "") or "" agent.ephemeral_system_prompt = new_prompt or None agent._cached_system_prompt = None elif name == "compress" and agent: with session["history_lock"]: - _compress_session_history(session) + _compress_session_history(session, arg) _emit("session.info", sid, _session_info(agent)) elif name == "fast" and agent: mode = arg.lower() @@ -1788,8 +1977,9 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str): elif name == "stop": from tools.process_registry import ProcessRegistry ProcessRegistry().kill_all() - except Exception: - pass + except Exception as e: + return f"live session sync failed: {e}" + return "" @method("slash.exec") @@ -1812,8 +2002,11 @@ def _(rid, params: dict) -> dict: try: output = worker.run(cmd) - _mirror_slash_side_effects(params.get("session_id", ""), session, cmd) - return _ok(rid, {"output": output or "(no output)"}) + warning = _mirror_slash_side_effects(params.get("session_id", ""), session, cmd) + payload = {"output": output or "(no output)"} + if warning: + payload["warning"] = warning + return _ok(rid, payload) except Exception as e: try: worker.close() @@ -1829,9 +2022,14 @@ def _(rid, params: dict) -> dict: def _(rid, params: dict) -> dict: action = params.get("action", "status") if action == "status": - return _ok(rid, {"enabled": os.environ.get("HERMES_VOICE", "0") == "1"}) + env = os.environ.get("HERMES_VOICE", "").strip() + if env in {"0", "1"}: + return _ok(rid, {"enabled": env == "1"}) + return _ok(rid, {"enabled": bool(_load_cfg().get("display", {}).get("voice_enabled", False))}) if action in ("on", "off"): - os.environ["HERMES_VOICE"] = "1" if action == "on" else "0" + enabled = action == "on" + os.environ["HERMES_VOICE"] = "1" if enabled else "0" + _write_config_key("display.voice_enabled", enabled) return _ok(rid, {"enabled": action == "on"}) return _err(rid, 4013, f"unknown voice action: {action}") @@ -1965,12 +2163,34 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"connected": bool(url), "url": url}) if action == "connect": url = params.get("url", "http://localhost:9222") - os.environ["BROWSER_CDP_URL"] = url try: + import urllib.request + from urllib.parse import urlparse from tools.browser_tool import cleanup_all_browsers + + parsed = urlparse(url if "://" in url else f"http://{url}") + if parsed.scheme not in {"http", "https", "ws", "wss"}: + return _err(rid, 4015, f"unsupported browser url: {url}") + probe_root = ( + f"{'https' if parsed.scheme == 'wss' else 'http' if parsed.scheme == 'ws' else parsed.scheme}://{parsed.netloc}" + ) + probe_urls = [f"{probe_root.rstrip('/')}/json/version", f"{probe_root.rstrip('/')}/json"] + ok = False + for probe in probe_urls: + try: + with urllib.request.urlopen(probe, timeout=2.0) as resp: + if 200 <= getattr(resp, "status", 200) < 300: + ok = True + break + except Exception: + continue + if not ok: + return _err(rid, 5031, f"could not reach browser CDP at {url}") + + os.environ["BROWSER_CDP_URL"] = url cleanup_all_browsers() - except Exception: - pass + except Exception as e: + return _err(rid, 5031, str(e)) return _ok(rid, {"connected": True, "url": url}) if action == "disconnect": os.environ.pop("BROWSER_CDP_URL", None) @@ -1990,8 +2210,8 @@ def _(rid, params: dict) -> dict: return _ok(rid, {"plugins": [ {"name": n, "version": getattr(i, "version", "?"), "enabled": getattr(i, "enabled", True)} for n, i in get_plugin_manager()._plugins.items()]}) - except Exception: - return _ok(rid, {"plugins": []}) + except Exception as e: + return _err(rid, 5032, str(e)) @method("config.show") diff --git a/ui-tui/.gitignore b/ui-tui/.gitignore index fc8abe6960..c5323f8723 100644 --- a/ui-tui/.gitignore +++ b/ui-tui/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ src/*.js +docs/ \ No newline at end of file diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e9152f1b3b..6e69ba42ce 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -9,6 +9,8 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { Banner, Panel, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' +import { ModelPicker } from './components/modelPicker.js' +import { PasteShelf } from './components/pasteShelf.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' @@ -126,6 +128,16 @@ const imageTokenMeta = (info: { height?: number; token_estimate?: number; width? return [dims, tok].filter(Boolean).join(' · ') } +const looksLikeSlashCommand = (text: string) => { + if (!text.startsWith('/')) { + return false + } + + const first = text.split(/\s+/, 1)[0] || '' + + return !first.slice(1).includes('/') +} + const toTranscriptMessages = (rows: unknown): Msg[] => { if (!Array.isArray(rows)) { return [] @@ -347,6 +359,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [approval, setApproval] = useState(null) const [sudo, setSudo] = useState(null) const [secret, setSecret] = useState(null) + const [modelPicker, setModelPicker] = useState(false) const [picker, setPicker] = useState(false) const [reasoning, setReasoning] = useState('') const [reasoningActive, setReasoningActive] = useState(false) @@ -386,10 +399,12 @@ export function App({ gw }: { gw: GatewayClient }) { const reasoningStreamingTimerRef = useRef | null>(null) const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) + const sidRef = useRef(sid) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const configMtimeRef = useRef(0) colsRef.current = cols busyRef.current = busy + sidRef.current = sid reasoningRef.current = reasoning // ── Hooks ──────────────────────────────────────────────────────── @@ -433,7 +448,7 @@ export function App({ gw }: { gw: GatewayClient }) { ) function blocked() { - return !!(clarify || approval || picker || secret || sudo || pager) + return !!(clarify || approval || modelPicker || picker || secret || sudo || pager) } const empty = !messages.length @@ -489,6 +504,15 @@ export function App({ gw }: { gw: GatewayClient }) { [appendMessage] ) + const maybeWarn = useCallback( + (value: any) => { + if (value?.warning) { + sys(`warning: ${value.warning}`) + } + }, + [sys] + ) + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { setActivity(prev => { const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev @@ -553,25 +577,29 @@ export function App({ gw }: { gw: GatewayClient }) { setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l))) setTurnTrail(turnToolsRef.current) - gw.request('clarify.respond', { answer, request_id: clarify.requestId }).catch(() => {}) + rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { + if (!r) { + return + } - if (answer) { - persistedToolLabelsRef.current.add(label) - appendMessage({ - role: 'system', - text: '', - kind: 'trail', - tools: [buildToolTrailLine('clarify', clarify.question)] - }) - appendMessage({ role: 'user', text: answer }) - } else { - sys('prompt cancelled') - } + if (answer) { + persistedToolLabelsRef.current.add(label) + appendMessage({ + role: 'system', + text: '', + kind: 'trail', + tools: [buildToolTrailLine('clarify', clarify.question)] + }) + appendMessage({ role: 'user', text: answer }) + setStatus('running…') + } else { + sys('prompt cancelled') + } - setClarify(null) - setStatus('running…') + setClarify(null) + }) }, - [appendMessage, clarify, gw, sys] + [appendMessage, clarify, rpc, sys] ) useEffect(() => { @@ -650,6 +678,7 @@ export function App({ gw }: { gw: GatewayClient }) { setVoiceRecording(false) setVoiceProcessing(false) setSid(null as any) // will be set by caller + setInfo(null) setHistoryItems([]) setMessages([]) setPastes([]) @@ -661,11 +690,64 @@ export function App({ gw }: { gw: GatewayClient }) { protocolWarnedRef.current = false } + const resetVisibleHistory = (info: SessionInfo | null = null) => { + idle() + setReasoning('') + setMessages([]) + setHistoryItems(info ? [introMsg(info)] : []) + setInfo(info) + setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO) + setActivity([]) + setLastUserMsg('') + turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() + } + + const trimLastExchange = (items: Msg[]) => { + const q = [...items] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + if (q.at(-1)?.role === 'user') { + q.pop() + } + + return q + } + + const guardBusySessionSwitch = useCallback( + (what = 'switch sessions') => { + if (!busyRef.current) { + return false + } + + sys(`interrupt the current turn before trying to ${what}`) + + return true + }, + [sys] + ) + + const closeSession = useCallback( + (targetSid?: string | null) => { + if (!targetSid) { + return Promise.resolve(null) + } + + return rpc('session.close', { session_id: targetSid }) + }, + [rpc] + ) + // ── Session management ─────────────────────────────────────────── const newSession = useCallback( - (msg?: string) => - rpc('session.create', { cols: colsRef.current }).then((r: any) => { + async (msg?: string) => { + await closeSession(sidRef.current) + + return rpc('session.create', { cols: colsRef.current }).then((r: any) => { if (!r) { setStatus('ready') @@ -696,45 +778,49 @@ export function App({ gw }: { gw: GatewayClient }) { if (msg) { sys(msg) } - }), - [rpc, sys] + }) + }, + [closeSession, rpc, sys] ) const resumeById = useCallback( (id: string) => { setPicker(false) setStatus('resuming…') - gw.request('session.resume', { cols: colsRef.current, session_id: id }) - .then((raw: any) => { - const r = asRpcResult(raw) + closeSession(sidRef.current === id ? null : sidRef.current).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then((raw: any) => { + const r = asRpcResult(raw) - if (!r) { - sys('error: invalid response: session.resume') + if (!r) { + sys('error: invalid response: session.resume') + setStatus('ready') + + return + } + + resetSession() + setSid(r.session_id) + setSessionStartedAt(Date.now()) + setInfo(r.info ?? null) + const resumed = toTranscriptMessages(r.messages) + + if (r.info?.usage) { + setUsage(prev => ({ ...prev, ...r.info.usage })) + } + + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) setStatus('ready') - - return - } - - resetSession() - setSid(r.session_id) - setSessionStartedAt(Date.now()) - setInfo(r.info ?? null) - const resumed = toTranscriptMessages(r.messages) - - if (r.info?.usage) { - setUsage(prev => ({ ...prev, ...r.info.usage })) - } - - setMessages(resumed) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - setStatus('ready') - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - }) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + setStatus('ready') + }) + ) }, - [gw, sys] + [closeSession, gw, sys] ) // ── Paste pipeline ─────────────────────────────────────────────── @@ -815,13 +901,13 @@ export function App({ gw }: { gw: GatewayClient }) { return null } - if (bracketed) { - void paste(true) - } - const cleanedText = stripTrailingPasteNewlines(text) - if (!cleanedText) { + if (!cleanedText || !/[^\n]/.test(cleanedText)) { + if (bracketed) { + void paste(true) + } + return null } @@ -904,7 +990,7 @@ export function App({ gw }: { gw: GatewayClient }) { }) } - gw.request('input.detect_drop', { session_id: sid, text: payload.text }) + gw.request('input.detect_drop', { session_id: sid, text }) .then((r: any) => { if (r?.matched) { if (r.is_image) { @@ -1029,7 +1115,13 @@ export function App({ gw }: { gw: GatewayClient }) { const dispatchSubmission = useCallback( (full: string) => { - if (!full.trim() || !sid) { + if (!full.trim()) { + return + } + + if (!sid) { + sys('session not ready yet') + return } @@ -1040,7 +1132,7 @@ export function App({ gw }: { gw: GatewayClient }) { historyDraftRef.current = '' } - if (full.startsWith('/')) { + if (looksLikeSlashCommand(full)) { appendMessage({ role: 'system', text: full, kind: 'slash' }) pushHistory(full) slashRef.current(full) @@ -1137,17 +1229,34 @@ export function App({ gw }: { gw: GatewayClient }) { if (clarify) { answerClarify('') } else if (approval) { - gw.request('approval.respond', { choice: 'deny', session_id: sid }).catch(() => {}) - setApproval(null) - sys('denied') + rpc('approval.respond', { choice: 'deny', session_id: sid }).then(r => { + if (!r) { + return + } + + setApproval(null) + sys('denied') + }) } else if (sudo) { - gw.request('sudo.respond', { request_id: sudo.requestId, password: '' }).catch(() => {}) - setSudo(null) - sys('sudo cancelled') + rpc('sudo.respond', { request_id: sudo.requestId, password: '' }).then(r => { + if (!r) { + return + } + + setSudo(null) + sys('sudo cancelled') + }) } else if (secret) { - gw.request('secret.respond', { request_id: secret.requestId, value: '' }).catch(() => {}) - setSecret(null) - sys('secret entry cancelled') + rpc('secret.respond', { request_id: secret.requestId, value: '' }).then(r => { + if (!r) { + return + } + + setSecret(null) + sys('secret entry cancelled') + }) + } else if (modelPicker) { + setModelPicker(false) } else if (picker) { setPicker(false) } @@ -1164,11 +1273,12 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (!inputBuf.length && key.tab && completions.length) { + if (key.tab && completions.length) { const row = completions[compIdx] - if (row) { - setInput(input.slice(0, compReplace) + row.text) + if (row?.text) { + const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + setInput(input.slice(0, compReplace) + text) } return @@ -1255,6 +1365,10 @@ export function App({ gw }: { gw: GatewayClient }) { } if (ctrl(key, ch, 'l')) { + if (guardBusySessionSwitch()) { + return + } + setStatus('forging session…') newSession() @@ -1319,6 +1433,10 @@ export function App({ gw }: { gw: GatewayClient }) { const onEvent = useCallback( (ev: GatewayEvent) => { + if (ev.session_id && sidRef.current && ev.session_id !== sidRef.current && !ev.type.startsWith('gateway.')) { + return + } + const p = ev.payload as any switch (ev.type) { @@ -1342,8 +1460,12 @@ export function App({ gw }: { gw: GatewayClient }) { skillCount: (r.skill_count ?? 0) as number, sub: (r.sub ?? {}) as Record }) + + if (r.warning) { + pushActivity(String(r.warning), 'warn') + } }) - .catch(() => {}) + .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) if (STARTUP_RESUME_ID) { setStatus('resuming…') @@ -1397,6 +1519,10 @@ export function App({ gw }: { gw: GatewayClient }) { break case 'thinking.delta': + if (p && Object.prototype.hasOwnProperty.call(p, 'text')) { + setStatus(p.text ? String(p.text) : busyRef.current ? 'running…' : 'ready') + } + break case 'message.start': @@ -1438,19 +1564,43 @@ export function App({ gw }: { gw: GatewayClient }) { case 'gateway.stderr': if (p?.line) { - pushActivity(String(p.line).slice(0, 120), 'error') + const line = String(p.line).slice(0, 120) + const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn' + pushActivity(line, tone) } break + case 'gateway.start_timeout': + setStatus('gateway startup timeout') + pushActivity( + `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`, + 'error' + ) + + break + case 'gateway.protocol_error': setStatus('protocol warning') + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + setStatus(busyRef.current ? 'running…' : 'ready') + }, 4000) + if (!protocolWarnedRef.current) { protocolWarnedRef.current = true pushActivity('protocol noise detected · /logs to inspect', 'warn') } + if (p?.preview) { + pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn') + } + break case 'reasoning.delta': @@ -1663,11 +1813,13 @@ export function App({ gw }: { gw: GatewayClient }) { bellOnComplete, dequeue, endReasoningPhase, + gw, newSession, pruneTransient, pulseReasoningStreaming, pushActivity, pushTrail, + rpc, sendQueued, sys, stdout @@ -1681,7 +1833,10 @@ export function App({ gw }: { gw: GatewayClient }) { const exitHandler = () => { setStatus('gateway exited') - exit() + setSid(null) + setBusy(false) + pushActivity('gateway exited · /logs to inspect', 'error') + sys('error: gateway exited') } gw.on('event', handler) @@ -1691,14 +1846,16 @@ export function App({ gw }: { gw: GatewayClient }) { return () => { gw.off('event', handler) gw.off('exit', exitHandler) + gw.kill() } - }, [gw, exit]) + }, [gw, pushActivity, sys]) // ── Slash commands ─────────────────────────────────────────────── const slash = useCallback( (cmd: string): boolean => { - const [name, ...rest] = cmd.slice(1).split(/\s+/) + const [rawName, ...rest] = cmd.slice(1).split(/\s+/) + const name = rawName.toLowerCase() const arg = rest.join(' ') switch (name) { @@ -1729,18 +1886,30 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'clear': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + setStatus('forging session…') newSession() return true case 'new': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + setStatus('forging session…') newSession('new session started') return true case 'resume': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + if (arg) { resumeById(arg) } else { @@ -1750,13 +1919,33 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'compact': - setCompact(c => (arg ? true : !c)) - sys(arg ? `compact on, focus: ${arg}` : `compact ${compact ? 'off' : 'on'}`) + if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { + sys('usage: /compact [on|off|toggle]') + + return true + } + + { + const mode = arg.trim().toLowerCase() + setCompact(current => { + const next = mode === 'on' ? true : mode === 'off' ? false : !current + queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) + + return next + }) + } return true case 'copy': { const all = messages.filter(m => m.role === 'assistant') - const target = all[arg ? Math.min(parseInt(arg), all.length) - 1 : all.length - 1] + + if (arg && Number.isNaN(parseInt(arg, 10))) { + sys('usage: /copy [number]') + + return true + } + + const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] if (!target) { sys('nothing to copy') @@ -1765,7 +1954,7 @@ export function App({ gw }: { gw: GatewayClient }) { } writeOsc52Clipboard(target.text) - sys('copied to clipboard') + sys('sent OSC52 copy sequence (terminal support required)') return true } @@ -1815,7 +2004,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - const re = new RegExp(`\\s*\\[\\[paste:${id}\\]\\]\\s*`, 'g') + const re = new RegExp(`\\s*\\[\\[paste:${id}(?:[^\\n]*?)\\]\\]\\s*`, 'g') setPastes(prev => prev.filter(p => p.id !== id)) setInput(v => stripTokens(v, re)) setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean)) @@ -1854,8 +2043,12 @@ export function App({ gw }: { gw: GatewayClient }) { case 'statusbar': case 'sb': - setStatusBar(v => !v) - sys(`status bar ${statusBar ? 'off' : 'on'}`) + setStatusBar(current => { + const next = !current + queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) + + return next + }) return true @@ -1873,6 +2066,8 @@ export function App({ gw }: { gw: GatewayClient }) { case 'undo': if (!sid) { + sys('nothing to undo') + return true } @@ -1882,19 +2077,8 @@ export function App({ gw }: { gw: GatewayClient }) { } if (r.removed > 0) { - setMessages(prev => { - const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - if (q.at(-1)?.role === 'user') { - q.pop() - } - - return q - }) + setMessages(prev => trimLastExchange(prev)) + setHistoryItems(prev => trimLastExchange(prev)) sys(`undid ${r.removed} messages`) } else { sys('nothing to undo') @@ -1911,18 +2095,25 @@ export function App({ gw }: { gw: GatewayClient }) { } if (sid) { - gw.request('session.undo', { session_id: sid }).catch(() => {}) + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (r.removed <= 0) { + sys('nothing to retry') + + return + } + + setMessages(prev => trimLastExchange(prev)) + setHistoryItems(prev => trimLastExchange(prev)) + send(lastUserMsg) + }) + + return true } - setMessages(prev => { - const q = [...prev] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - return q - }) send(lastUserMsg) return true @@ -1966,37 +2157,28 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'model': + if (guardBusySessionSwitch('change models')) { + return true + } + if (!arg) { - rpc('config.get', { key: 'provider' }).then((r: any) => { + setModelPicker(true) + } else { + rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { if (!r) { return } - panel('Model', [ - { - rows: [ - ['Model', r.model], - ['Provider', r.provider] - ] - } - ]) - }) - } else { - rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then( - (r: any) => { - if (!r?.value) { - return - } + if (!r.value) { + sys('error: invalid response: model switch') - sys(`model → ${r.value}`) - - if (r.warning) { - sys(`warning: ${r.warning}`) - } - - setInfo(prev => (prev ? { ...prev, model: r.value } : prev)) + return } - ) + + sys(`model → ${r.value}`) + maybeWarn(r) + setInfo(prev => (prev ? { ...prev, model: r.value } : { model: r.value, skills: {}, tools: {} })) + }) } return true @@ -2019,7 +2201,12 @@ export function App({ gw }: { gw: GatewayClient }) { case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => page(r?.output || '(no output)', 'Provider')) + .then((r: any) => { + page( + r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', + 'Provider' + ) + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true @@ -2057,13 +2244,23 @@ export function App({ gw }: { gw: GatewayClient }) { return true case 'reasoning': - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => { - if (!r?.value) { - return - } + if (!arg) { + rpc('config.get', { key: 'reasoning' }).then((r: any) => { + if (!r?.value) { + return + } - sys(`reasoning: ${r.value}`) - }) + sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + }) + } else { + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { + if (!r?.value) { + return + } + + sys(`reasoning: ${r.value}`) + }) + } return true @@ -2080,28 +2277,61 @@ export function App({ gw }: { gw: GatewayClient }) { case 'personality': if (arg) { - rpc('config.set', { key: 'personality', value: arg }).then((r: any) => { + rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { if (!r) { return } - sys(`personality: ${r.value || 'default'}`) + if (r.history_reset) { + resetVisibleHistory(r.info ?? null) + } + + sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) + maybeWarn(r) }) } else { gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }])) + .then((r: any) => { + panel('Personality', [ + { + text: r?.warning + ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` + : r?.output || '(no output)' + } + ]) + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } return true case 'compress': - rpc('session.compress', { session_id: sid }).then((r: any) => { + rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { if (!r) { return } - sys(`compressed${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) + if (Array.isArray(r.messages)) { + const resumed = toTranscriptMessages(r.messages) + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + } + + if (r.info) { + setInfo(r.info) + } + + if (r.usage) { + setUsage(prev => ({ ...prev, ...r.usage })) + } + + if ((r.removed ?? 0) <= 0) { + sys('nothing to compress') + + return + } + + sys(`compressed ${r.removed} messages${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) }) return true @@ -2112,7 +2342,7 @@ export function App({ gw }: { gw: GatewayClient }) { return } - sys(`killed ${r.killed ?? 0} process(es)`) + sys(`killed ${r.killed ?? 0} registered process(es)`) }) return true @@ -2120,15 +2350,19 @@ export function App({ gw }: { gw: GatewayClient }) { case 'branch': case 'fork': - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (r?.session_id) { - setSid(r.session_id) - setSessionStartedAt(Date.now()) - setHistoryItems([]) - setMessages([]) - sys(`branched → ${r.title}`) - } - }) + { + const prevSid = sid + rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { + if (r?.session_id) { + void closeSession(prevSid) + setSid(r.session_id) + setSessionStartedAt(Date.now()) + setHistoryItems([]) + setMessages([]) + sys(`branched → ${r.title}`) + } + }) + } return true @@ -2249,7 +2483,7 @@ export function App({ gw }: { gw: GatewayClient }) { } setVoiceEnabled(!!r?.enabled) - sys(`voice${arg === 'on' || arg === 'off' ? '' : ':'} ${r.enabled ? 'on' : 'off'}`) + sys(`voice: ${r.enabled ? 'on' : 'off'}`) }) return true @@ -2411,7 +2645,13 @@ export function App({ gw }: { gw: GatewayClient }) { } gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => sys(r?.output || '/skills: no output')) + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` + : r?.output || '/skills: no output' + ) + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true @@ -2481,7 +2721,9 @@ export function App({ gw }: { gw: GatewayClient }) { .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } else { gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => sys(r?.output || '(no output)')) + .then((r: any) => { + sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') + }) .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) } @@ -2558,14 +2800,20 @@ export function App({ gw }: { gw: GatewayClient }) { default: gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => sys(r?.output || `/${name}: no output`)) - .catch((e: unknown) => { + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}` + : r?.output || `/${name}: no output` + ) + }) + .catch(() => { gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) .then((raw: any) => { const d = asRpcResult(raw) if (!d?.type) { - sys(`error: ${rpcErrorMessage(e)}`) + sys('error: invalid response: command.dispatch') return } @@ -2586,7 +2834,7 @@ export function App({ gw }: { gw: GatewayClient }) { } } }) - .catch(() => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) }) return true @@ -2595,8 +2843,10 @@ export function App({ gw }: { gw: GatewayClient }) { [ catalog, compact, + guardBusySessionSwitch, gw, lastUserMsg, + maybeWarn, messages, newSession, page, @@ -2604,6 +2854,7 @@ export function App({ gw }: { gw: GatewayClient }) { pastes, pushActivity, rpc, + resetVisibleHistory, send, sid, statusBar, @@ -2755,10 +3006,15 @@ export function App({ gw }: { gw: GatewayClient }) { { - gw.request('approval.respond', { choice, session_id: sid }).catch(() => {}) - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') + rpc('approval.respond', { choice, session_id: sid }).then(r => { + if (!r) { + return + } + + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }) }} req={approval} t={theme} @@ -2773,9 +3029,14 @@ export function App({ gw }: { gw: GatewayClient }) { icon="🔐" label="sudo password required" onSubmit={pw => { - gw.request('sudo.respond', { request_id: sudo.requestId, password: pw }).catch(() => {}) - setSudo(null) - setStatus('running…') + rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + setSudo(null) + setStatus('running…') + }) }} t={theme} /> @@ -2789,9 +3050,14 @@ export function App({ gw }: { gw: GatewayClient }) { icon="🔑" label={secret.prompt} onSubmit={val => { - gw.request('secret.respond', { request_id: secret.requestId, value: val }).catch(() => {}) - setSecret(null) - setStatus('running…') + rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { + if (!r) { + return + } + + setSecret(null) + setStatus('running…') + }) }} sub={`for ${secret.envVar}`} t={theme} @@ -2805,11 +3071,28 @@ export function App({ gw }: { gw: GatewayClient }) { )} + {modelPicker && ( + + setModelPicker(false)} + onSelect={value => { + setModelPicker(false) + slash(`/model ${value}`) + }} + sessionId={sid} + t={theme} + /> + + )} + + + {bgTasks.size > 0 && ( - {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running · /stop to cancel + {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running )} diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx new file mode 100644 index 0000000000..54e6733f8a --- /dev/null +++ b/ui-tui/src/components/modelPicker.tsx @@ -0,0 +1,241 @@ +import { Box, Text, useInput } from '@hermes/ink' +import { useEffect, useState } from 'react' + +import type { GatewayClient } from '../gatewayClient.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { Theme } from '../theme.js' + +interface ProviderItem { + is_current?: boolean + models?: string[] + name: string + slug: string + total_models?: number + warning?: string +} + +const VISIBLE = 12 + +const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) + +export function ModelPicker({ + gw, + onCancel, + onSelect, + sessionId, + t +}: { + gw: GatewayClient + onCancel: () => void + onSelect: (value: string) => void + sessionId: string | null + t: Theme +}) { + const [providers, setProviders] = useState([]) + const [currentModel, setCurrentModel] = useState('') + const [err, setErr] = useState('') + const [loading, setLoading] = useState(true) + const [persistGlobal, setPersistGlobal] = useState(false) + const [providerIdx, setProviderIdx] = useState(0) + const [modelIdx, setModelIdx] = useState(0) + const [stage, setStage] = useState<'model' | 'provider'>('provider') + + useEffect(() => { + gw.request('model.options', sessionId ? { session_id: sessionId } : {}) + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + setErr('invalid response: model.options') + setLoading(false) + + return + } + + const next = (r.providers ?? []) as ProviderItem[] + setProviders(next) + setCurrentModel(String(r.model ?? '')) + setProviderIdx( + Math.max( + 0, + next.findIndex(p => p.is_current) + ) + ) + setModelIdx(0) + setErr('') + setLoading(false) + }) + .catch((e: unknown) => { + setErr(rpcErrorMessage(e)) + setLoading(false) + }) + }, [gw, sessionId]) + + const provider = providers[providerIdx] + const models = provider?.models ?? [] + + const visibleItems = (items: string[], sel: number) => { + const off = pageOffset(items.length, sel) + + return { items: items.slice(off, off + VISIBLE), off } + } + + useInput((ch, key) => { + if (key.escape) { + if (stage === 'model') { + setStage('provider') + setModelIdx(0) + + return + } + + onCancel() + + return + } + + const count = stage === 'provider' ? providers.length : models.length + const sel = stage === 'provider' ? providerIdx : modelIdx + const setSel = stage === 'provider' ? setProviderIdx : setModelIdx + + if (key.upArrow && sel > 0) { + setSel(v => v - 1) + + return + } + + if (key.downArrow && sel < count - 1) { + setSel(v => v + 1) + + return + } + + if (key.return) { + if (stage === 'provider') { + if (!provider) { + return + } + + setStage('model') + setModelIdx(0) + + return + } + + const model = models[modelIdx] + + if (provider && model) { + onSelect(`${model} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) + } else { + setStage('provider') + } + + return + } + + if (ch.toLowerCase() === 'g') { + setPersistGlobal(v => !v) + + return + } + + const n = ch === '0' ? 10 : parseInt(ch, 10) + + if (!Number.isNaN(n) && n >= 1 && n <= Math.min(10, count)) { + const off = pageOffset(count, sel) + + if (stage === 'provider') { + const next = off + n - 1 + + if (providers[next]) { + setProviderIdx(next) + } + } else if (provider && models[off + n - 1]) { + onSelect(`${models[off + n - 1]} --provider ${provider.slug}${persistGlobal ? ' --global' : ''}`) + } + } + }) + + if (loading) { + return loading models… + } + + if (err) { + return ( + + error: {err} + Esc to cancel + + ) + } + + if (!providers.length) { + return ( + + no authenticated providers + Esc to cancel + + ) + } + + if (stage === 'provider') { + const rows = providers.map( + p => `${p.is_current ? '*' : ' '} ${p.name} · ${p.total_models ?? p.models?.length ?? 0} models` + ) + + const { items, off } = visibleItems(rows, providerIdx) + + return ( + + + Select Provider + + Current model: {currentModel || '(unknown)'} + {provider?.warning ? warning: {provider.warning} : null} + {off > 0 && ↑ {off} more} + {items.map((row, i) => { + const idx = off + i + + return ( + + {providerIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} + persist: {persistGlobal ? 'global' : 'session'} · g toggle + ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel + + ) + } + + const { items, off } = visibleItems(models, modelIdx) + + return ( + + + Select Model + + {provider?.name || '(unknown provider)'} + {!models.length ? no models listed for this provider : null} + {provider?.warning ? warning: {provider.warning} : null} + {off > 0 && ↑ {off} more} + {items.map((row, i) => { + const idx = off + i + + return ( + + {modelIdx === idx ? '▸ ' : ' '} + {i + 1}. {row} + + ) + })} + {off + VISIBLE < models.length && ↓ {models.length - off - VISIBLE} more} + persist: {persistGlobal ? 'global' : 'session'} · g toggle + + {models.length ? '↑/↓ select · Enter switch · 1-9,0 quick · Esc back' : 'Enter/Esc back'} + + + ) +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 385dd5f48b..edc586e937 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -30,10 +30,68 @@ const dim = (s: string) => DIM + s + DIM_OFF let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) +function graphemeStops(s: string) { + const stops = [0] + + for (const { index } of seg().segment(s)) { + if (index > 0) { + stops.push(index) + } + } + + if (stops.at(-1) !== s.length) { + stops.push(s.length) + } + + return stops +} + +function snapPos(s: string, p: number) { + const pos = Math.max(0, Math.min(p, s.length)) + let last = 0 + + for (const stop of graphemeStops(s)) { + if (stop > pos) { + break + } + + last = stop + } + + return last +} + +function prevPos(s: string, p: number) { + const pos = snapPos(s, p) + let prev = 0 + + for (const stop of graphemeStops(s)) { + if (stop >= pos) { + return prev + } + + prev = stop + } + + return prev +} + +function nextPos(s: string, p: number) { + const pos = snapPos(s, p) + + for (const stop of graphemeStops(s)) { + if (stop > pos) { + return stop + } + } + + return s.length +} + // ── Word movement ──────────────────────────────────────────────────── function wordLeft(s: string, p: number) { - let i = p - 1 + let i = snapPos(s, p) - 1 while (i > 0 && /\s/.test(s[i]!)) { i-- @@ -47,7 +105,7 @@ function wordLeft(s: string, p: number) { } function wordRight(s: string, p: number) { - let i = p + let i = snapPos(s, p) while (i < s.length && !/\s/.test(s[i]!)) { i++ @@ -252,7 +310,7 @@ export function TextInput({ const commit = (next: string, nextCur: number, track = true) => { const prev = vRef.current - const c = Math.max(0, Math.min(nextCur, next.length)) + const c = snapPos(next, nextCur) if (track && next !== prev) { undo.current.push({ cursor: curRef.current, value: prev }) @@ -316,11 +374,10 @@ export function TextInput({ useInput( (inp: string, k: Key, event: InputEvent) => { - // Some terminals normalize Ctrl+V to "v"; others deliver raw ^V (\x16). - const ctrlPaste = k.ctrl && (inp.toLowerCase() === 'v' || event.keypress.raw === '\x16') - const metaPaste = k.meta && inp.toLowerCase() === 'v' + const raw = event.keypress.raw + const metaPaste = raw === '\x1bv' || raw === '\x1bV' - if (ctrlPaste || metaPaste) { + if (metaPaste) { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } @@ -366,9 +423,9 @@ export function TextInput({ } else if (k.end || (k.ctrl && inp === 'e')) { c = v.length } else if (k.leftArrow) { - c = mod ? wordLeft(v, c) : Math.max(0, c - 1) + c = mod ? wordLeft(v, c) : prevPos(v, c) } else if (k.rightArrow) { - c = mod ? wordRight(v, c) : Math.min(v.length, c + 1) + c = mod ? wordRight(v, c) : nextPos(v, c) } else if (k.meta && inp === 'b') { c = wordLeft(v, c) } else if (k.meta && inp === 'f') { @@ -382,15 +439,16 @@ export function TextInput({ v = v.slice(0, t) + v.slice(c) c = t } else { - v = v.slice(0, c - 1) + v.slice(c) - c-- + const t = prevPos(v, c) + v = v.slice(0, t) + v.slice(c) + c = t } } else if (k.delete && fwdDel.current && c < v.length) { if (mod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) } else { - v = v.slice(0, c) + v.slice(c + 1) + v = v.slice(0, c) + v.slice(nextPos(v, c)) } } else if (k.ctrl && inp === 'w' && c > 0) { const t = wordLeft(v, c) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 9f1c487711..9e7cac9999 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -25,7 +25,7 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'new session (clear)'], ['Ctrl+T', 'cycle thinking detail'], - ['Ctrl+V / Alt+V', 'paste clipboard image'], + ['Alt+V / /paste', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], ['Ctrl+A/E', 'home / end of line'], diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 40bd77763c..2c98c64e04 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -5,6 +5,8 @@ import { createInterface } from 'node:readline' const MAX_GATEWAY_LOG_LINES = 200 const MAX_LOG_PREVIEW = 240 +const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000) +const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000) export interface GatewayEvent { type: string @@ -23,27 +25,78 @@ export class GatewayClient extends EventEmitter { private logs: string[] = [] private pending = new Map() private bufferedEvents: GatewayEvent[] = [] + private pendingExit: number | null | undefined + private ready = false + private readyTimer: ReturnType | null = null private subscribed = false + private stdoutRl: ReturnType | null = null + private stderrRl: ReturnType | null = null + + private publish(ev: GatewayEvent) { + if (ev.type === 'gateway.ready') { + this.ready = true + + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + } + + if (this.subscribed) { + this.emit('event', ev) + + return + } + + this.bufferedEvents.push(ev) + } start() { const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') + const python = process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python') + const cwd = process.env.HERMES_CWD || root + this.ready = false + this.pendingExit = undefined + this.stdoutRl?.close() + this.stderrRl?.close() + this.stdoutRl = null + this.stderrRl = null - this.proc = spawn(process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python'), ['-m', 'tui_gateway.entry'], { - cwd: process.env.HERMES_CWD || root, + if (this.proc && !this.proc.killed && this.proc.exitCode === null) { + this.proc.kill() + } + + if (this.readyTimer) { + clearTimeout(this.readyTimer) + } + + this.readyTimer = setTimeout(() => { + if (this.ready) { + return + } + + this.pushLog(`[startup] timed out waiting for gateway.ready (python=${python}, cwd=${cwd})`) + this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } }) + }, STARTUP_TIMEOUT_MS) + + this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { + cwd, stdio: ['pipe', 'pipe', 'pipe'] }) - createInterface({ input: this.proc.stdout! }).on('line', raw => { + this.stdoutRl = createInterface({ input: this.proc.stdout! }) + this.stdoutRl.on('line', raw => { try { this.dispatch(JSON.parse(raw)) } catch { const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)' this.pushLog(`[protocol] malformed stdout: ${preview}`) - this.emit('event', { type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent) + this.publish({ type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent) } }) - createInterface({ input: this.proc.stderr! }).on('line', raw => { + this.stderrRl = createInterface({ input: this.proc.stderr! }) + this.stderrRl.on('line', raw => { const line = raw.trim() if (!line) { @@ -51,18 +104,28 @@ export class GatewayClient extends EventEmitter { } this.pushLog(line) - this.emit('event', { type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) }) this.proc.on('error', err => { this.pushLog(`[spawn] ${err.message}`) this.rejectPending(new Error(`gateway error: ${err.message}`)) - this.emit('event', { type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent) }) this.proc.on('exit', code => { + if (this.readyTimer) { + clearTimeout(this.readyTimer) + this.readyTimer = null + } + this.rejectPending(new Error(`gateway exited${code === null ? '' : ` (${code})`}`)) - this.emit('exit', code) + + if (this.subscribed) { + this.emit('exit', code) + } else { + this.pendingExit = code + } }) } @@ -78,13 +141,7 @@ export class GatewayClient extends EventEmitter { } if (msg.method === 'event') { - const ev = msg.params as GatewayEvent - - if (this.subscribed) { - this.emit('event', ev) - } else { - this.bufferedEvents.push(ev) - } + this.publish(msg.params as GatewayEvent) } } @@ -110,6 +167,12 @@ export class GatewayClient extends EventEmitter { for (const ev of pending) { this.emit('event', ev) } + + if (this.pendingExit !== undefined) { + const code = this.pendingExit + this.pendingExit = undefined + this.emit('exit', code) + } } getLogTail(limit = 20): string { @@ -117,6 +180,10 @@ export class GatewayClient extends EventEmitter { } request(method: string, params: Record = {}): Promise { + if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) { + this.start() + } + if (!this.proc?.stdin) { return Promise.reject(new Error('gateway not running')) } @@ -128,7 +195,7 @@ export class GatewayClient extends EventEmitter { if (this.pending.delete(id)) { reject(new Error(`timeout: ${method}`)) } - }, 30_000) + }, REQUEST_TIMEOUT_MS) this.pending.set(id, { reject: e => { diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 50054e90d0..1c74872c1d 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -2,7 +2,7 @@ import { startTransition, useEffect, useRef, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' -const TAB_PATH_RE = /((?:\.\.?\/|~\/|\/|@)[^\s]*)$/ +const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) @@ -59,7 +59,18 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) }) }) - .catch(() => {}) + .catch((e: unknown) => { + if (ref.current !== input) { + return + } + + const meta = e instanceof Error && e.message ? e.message : 'unavailable' + startTransition(() => { + setCompletions([{ text: '', display: 'completion unavailable', meta }]) + setCompIdx(0) + setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) + }) + }) }, 60) return () => clearTimeout(t) diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 1841bdd770..9c67b1e372 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -243,4 +243,4 @@ export const userDisplay = (text: string): string => { } export const isPasteBackedText = (text: string): boolean => - /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)\]/.test(text) + /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 784b69015e..e8d94e64c2 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -43,6 +43,7 @@ export interface SessionInfo { tools: Record update_behind?: number | null update_command?: string + usage?: Usage version?: string } From 6d6b3b03ac022ddab59255c3ff92f389a28f2f27 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 21:20:55 -0500 Subject: [PATCH 098/157] feat: add clicky handles --- hermes_cli/commands.py | 2 +- tui_gateway/entry.py | 2 + tui_gateway/server.py | 57 ++ ui-tui/README.md | 9 +- .../hermes-ink/src/utils/fullscreen.ts | 2 +- ui-tui/src/app.tsx | 871 ++++++++---------- ui-tui/src/components/messageLine.tsx | 62 +- ui-tui/src/components/pasteShelf.tsx | 48 - ui-tui/src/components/textInput.tsx | 58 +- ui-tui/src/components/thinking.tsx | 296 +++--- ui-tui/src/constants.ts | 1 - ui-tui/src/gatewayClient.ts | 6 +- ui-tui/src/hooks/useVirtualHistory.ts | 117 +++ ui-tui/src/types.ts | 15 +- ui-tui/src/types/hermes-ink.d.ts | 29 + 15 files changed, 819 insertions(+), 756 deletions(-) delete mode 100644 ui-tui/src/components/pasteShelf.tsx create mode 100644 ui-tui/src/hooks/useVirtualHistory.ts diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index e623700d88..964311fb7a 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -155,7 +155,7 @@ COMMAND_REGISTRY: list[CommandDef] = [ cli_only=True, aliases=("gateway",)), CommandDef("copy", "Copy the last assistant response to clipboard", "Info", cli_only=True, args_hint="[number]"), - CommandDef("paste", "Attach clipboard image or manage text paste shelf", "Info", + CommandDef("paste", "Attach clipboard image from your clipboard", "Info", cli_only=True), CommandDef("image", "Attach a local image file for your next prompt", "Info", cli_only=True, args_hint=""), diff --git a/tui_gateway/entry.py b/tui_gateway/entry.py index 9284ba28ef..a9667528de 100644 --- a/tui_gateway/entry.py +++ b/tui_gateway/entry.py @@ -5,6 +5,8 @@ import sys from tui_gateway.server import handle_request, resolve_skin, write_json signal.signal(signal.SIGPIPE, signal.SIG_DFL) +signal.signal(signal.SIGINT, signal.SIG_IGN) + def main(): if not write_json({ diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 86f3617e29..78cac4f880 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1499,6 +1499,42 @@ def _(rid, params: dict) -> dict: except Exception as e: return _err(rid, 5001, str(e)) + if key == "details_mode": + nv = str(value or "").strip().lower() + allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) + if nv not in allowed_dm: + return _err(rid, 4002, f"unknown details_mode: {value}") + _write_config_key("display.details_mode", nv) + return _ok(rid, {"key": key, "value": nv}) + + if key == "thinking_mode": + nv = str(value or "").strip().lower() + allowed_tm = frozenset({"collapsed", "truncated", "full"}) + if nv not in allowed_tm: + return _err(rid, 4002, f"unknown thinking_mode: {value}") + _write_config_key("display.thinking_mode", nv) + # Backward compatibility bridge: keep details_mode aligned. + _write_config_key("display.details_mode", "expanded" if nv == "full" else "collapsed") + return _ok(rid, {"key": key, "value": nv}) + + if key in ("compact", "statusbar"): + raw = str(value or "").strip().lower() + cfg0 = _load_cfg() + d0 = cfg0.get("display") if isinstance(cfg0.get("display"), dict) else {} + def_key = "tui_compact" if key == "compact" else "tui_statusbar" + cur_b = bool(d0.get(def_key, False if key == "compact" else True)) + if raw in ("", "toggle"): + nv_b = not cur_b + elif raw == "on": + nv_b = True + elif raw == "off": + nv_b = False + else: + return _err(rid, 4002, f"unknown {key} value: {value}") + _write_config_key(f"display.{def_key}", nv_b) + out = "on" if nv_b else "off" + return _ok(rid, {"key": key, "value": out}) + if key in ("prompt", "personality", "skin"): try: cfg = _load_cfg() @@ -1562,6 +1598,27 @@ def _(rid, params: dict) -> dict: effort = str(cfg.get("agent", {}).get("reasoning_effort", "medium") or "medium") display = "show" if bool(cfg.get("display", {}).get("show_reasoning", False)) else "hide" return _ok(rid, {"value": effort, "display": display}) + if key == "details_mode": + allowed_dm = frozenset({"hidden", "collapsed", "expanded"}) + raw = str(_load_cfg().get("display", {}).get("details_mode", "collapsed") or "collapsed").strip().lower() + nv = raw if raw in allowed_dm else "collapsed" + return _ok(rid, {"value": nv}) + if key == "thinking_mode": + allowed_tm = frozenset({"collapsed", "truncated", "full"}) + cfg = _load_cfg() + raw = str(cfg.get("display", {}).get("thinking_mode", "") or "").strip().lower() + if raw in allowed_tm: + nv = raw + else: + dm = str(cfg.get("display", {}).get("details_mode", "collapsed") or "collapsed").strip().lower() + nv = "full" if dm == "expanded" else "collapsed" + return _ok(rid, {"value": nv}) + if key == "compact": + on = bool(_load_cfg().get("display", {}).get("tui_compact", False)) + return _ok(rid, {"value": "on" if on else "off"}) + if key == "statusbar": + on = bool(_load_cfg().get("display", {}).get("tui_statusbar", True)) + return _ok(rid, {"value": "on" if on else "off"}) if key == "mtime": cfg_path = _hermes_home / "config.yaml" try: diff --git a/ui-tui/README.md b/ui-tui/README.md index 0f4f14e3ef..19d162e6da 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -150,10 +150,7 @@ Notes: - Queued drafts keep their original `!cmd` and `{!cmd}` text while you edit them. Shell commands and interpolation run when the queued item is actually sent. - If you load a queued item into the input and resubmit plain text, that queue item is replaced, removed from the queue preview, and promoted to send next. If the agent is still busy, the edited item is moved to the front of the queue and sent after the current run completes. - Completion requests are debounced by 60 ms. Input starting with `/` uses `complete.slash`. A trailing token that starts with `./`, `../`, `~/`, `/`, or `@` uses `complete.path`. -- Text pastes are captured into a local paste shelf and inserted as `[[paste:]]` tokens. Nothing is newline-flattened. -- Small pastes default to `excerpt` mode. Larger pastes default to `attach` mode. -- Very large paste references trigger a confirmation prompt before send. -- Pasted content is scanned for obvious secret patterns before send and redacted in the outbound payload. +- Text pastes are inserted inline directly into the draft. Nothing is newline-flattened. - `Ctrl+G` writes the current draft, including any multiline buffer, to a temp file, temporarily swaps screen buffers, launches `$EDITOR`, then restores the TUI and submits the saved text if the editor exits cleanly. - Input history is stored in `~/.hermes/.hermes_history` or under `HERMES_HOME`. @@ -192,6 +189,7 @@ The local slash handler covers the built-ins that need direct client behavior: - `/resume` - `/copy` - `/paste` +- `/details` - `/logs` - `/statusbar`, `/sb` - `/queue` @@ -202,7 +200,8 @@ Notes: - `/copy` sends the selected assistant response through OSC 52. - `/paste` with no args asks the gateway for clipboard image attachment state. -- `/paste list|mode|drop|clear` manages text paste-shelf items. +- `/paste` does not manage text paste entries; text paste is inline-only. +- `/details [hidden|collapsed|expanded|cycle]` controls thinking/tool-detail visibility. - `/statusbar` toggles the status rule on/off. Anything else falls through to: diff --git a/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts b/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts index 7ce9e87587..523a43102b 100644 --- a/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts +++ b/ui-tui/packages/hermes-ink/src/utils/fullscreen.ts @@ -1,3 +1,3 @@ export function isMouseClicksDisabled(): boolean { - return false + return /^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE_CLICKS ?? '').trim().toLowerCase()) } diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 6e69ba42ce..af8f607867 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -3,14 +3,24 @@ import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Box, Text, useApp, useInput, useStdout } from '@hermes/ink' -import { useCallback, useEffect, useRef, useState } from 'react' +import { + AlternateScreen, + Box, + NoSelect, + ScrollBox, + Text, + useApp, + useHasSelection, + useInput, + useSelection, + useStdout +} from '@hermes/ink' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Banner, Panel, SessionPanel } from './components/branding.js' import { MaskedPrompt } from './components/maskedPrompt.js' import { MessageLine } from './components/messageLine.js' import { ModelPicker } from './components/modelPicker.js' -import { PasteShelf } from './components/pasteShelf.js' import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' import { QueuedMessages } from './components/queuedMessages.js' import { SessionPicker } from './components/sessionPicker.js' @@ -21,17 +31,15 @@ import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' import { useCompletion } from './hooks/useCompletion.js' import { useInputHistory } from './hooks/useInputHistory.js' import { useQueue } from './hooks/useQueue.js' +import { useVirtualHistory } from './hooks/useVirtualHistory.js' import { writeOsc52Clipboard } from './lib/osc52.js' import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' import { buildToolTrailLine, - compactPreview, - estimateTokensRough, fmtK, hasInterpolation, isToolTrailResultLine, isTransientTrailLine, - pasteTokenLabel, pick, sameToolTrailGroup, stripTrailingPasteNewlines, @@ -43,82 +51,46 @@ import type { ActivityItem, ApprovalReq, ClarifyReq, + DetailsMode, Msg, PanelSection, - PasteMode, - PendingPaste, SecretReq, SessionInfo, SlashCatalog, SudoReq, - ThinkingMode, Usage } from './types.js' // ── Constants ──────────────────────────────────────────────────────── const PLACEHOLDER = pick(PLACEHOLDERS) -const PASTE_TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() -const LARGE_PASTE = { chars: 8000, lines: 80 } -const EXCERPT = { chars: 1200, lines: 14 } const MAX_HISTORY = 800 const REASONING_PULSE_MS = 700 +const WHEEL_SCROLL_STEP = 3 +const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()) -const SECRET_PATTERNS = [ - /AKIA[0-9A-Z]{16}/g, - /AIza[0-9A-Za-z-_]{30,}/g, - /gh[pousr]_[A-Za-z0-9]{20,}/g, - /sk-[A-Za-z0-9]{20,}/g, - /sk-ant-[A-Za-z0-9-]{20,}/g, - /xox[baprs]-[A-Za-z0-9-]{10,}/g, - /\b(?:api[_-]?key|token|secret)\b\s*[:=]\s*["']?[A-Za-z0-9_-]{12,}/gi -] +const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] + +const parseDetailsMode = (v: unknown): DetailsMode | null => { + const s = typeof v === 'string' ? v.trim().toLowerCase() : '' + return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null +} + +const resolveDetailsMode = (d: any): DetailsMode => + parseDetailsMode(d?.details_mode) + ?? ({ full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[ + String(d?.thinking_mode ?? '').trim().toLowerCase() + ] ?? 'collapsed') + +const nextDetailsMode = (m: DetailsMode): DetailsMode => + DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! // ── Pure helpers ───────────────────────────────────────────────────── const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) -const classifyPaste = (text: string): PendingPaste['kind'] => { - if (/error|warn|traceback|exception|stack|debug|\[\d{2}:\d{2}:\d{2}\]/i.test(text)) { - return 'log' - } - - if ( - /```|function\s+\w+|class\s+\w+|import\s+.+from|const\s+\w+\s*=|def\s+\w+\(|<\w+/.test(text) || - text.split('\n').filter(l => /[{}()[\];<>]/.test(l)).length >= 3 - ) { - return 'code' - } - - return 'text' -} - -const redactSecrets = (text: string) => { - let redactions = 0 - - const cleaned = SECRET_PATTERNS.reduce( - (t, pat) => - t.replace(pat, val => { - redactions++ - - return val.includes(':') || val.includes('=') - ? `${val.split(/[:=]/)[0]}: [REDACTED_SECRET]` - : '[REDACTED_SECRET]' - }), - text - ) - - return { redactions, text: cleaned } -} - -const stripTokens = (text: string, re: RegExp) => - text - .replace(re, '') - .replace(/\s{2,}/g, ' ') - .trim() - const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' @@ -366,7 +338,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [reasoningStreaming, setReasoningStreaming] = useState(false) const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') - const [pastes, setPastes] = useState([]) const [streaming, setStreaming] = useState('') const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) @@ -378,21 +349,19 @@ export function App({ gw }: { gw: GatewayClient }) { const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [bellOnComplete, setBellOnComplete] = useState(false) const [clockNow, setClockNow] = useState(() => Date.now()) - const [thinkingMode, setThinkingMode] = useState('truncated') + const [detailsMode, setDetailsMode] = useState('collapsed') // ── Refs ───────────────────────────────────────────────────────── const activityIdRef = useRef(0) const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null) const buf = useRef('') - const inflightPasteIdsRef = useRef([]) const interruptedRef = useRef(false) const reasoningRef = useRef('') const slashRef = useRef<(cmd: string) => boolean>(() => false) const lastEmptyAt = useRef(0) const lastStatusNoteRef = useRef('') const protocolWarnedRef = useRef(false) - const pasteCounterRef = useRef(0) const colsRef = useRef(cols) const turnToolsRef = useRef([]) const persistedToolLabelsRef = useRef>(new Set()) @@ -400,6 +369,7 @@ export function App({ gw }: { gw: GatewayClient }) { const statusTimerRef = useRef | null>(null) const busyRef = useRef(busy) const sidRef = useRef(sid) + const scrollRef = useRef(null) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const configMtimeRef = useRef(0) colsRef.current = cols @@ -409,6 +379,9 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Hooks ──────────────────────────────────────────────────────── + const hasSelection = useHasSelection() + const selection = useSelection() + const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = useQueue() @@ -453,7 +426,16 @@ export function App({ gw }: { gw: GatewayClient }) { const empty = !messages.length const isBlocked = blocked() - const hasAnyThinking = Boolean(reasoning.trim() || historyItems.some(m => m.thinking?.trim())) + const virtualRows = useMemo( + () => + historyItems.map((msg, index) => ({ + index, + key: `${index}:${msg.role}:${msg.kind ?? ''}:${msg.text.slice(0, 40)}`, + msg + })), + [historyItems] + ) + const virtualHistory = useVirtualHistory(scrollRef, virtualRows) // ── Resize RPC ─────────────────────────────────────────────────── @@ -612,7 +594,11 @@ export function App({ gw }: { gw: GatewayClient }) { configMtimeRef.current = Number(r?.mtime ?? 0) }) rpc('config.get', { key: 'full' }).then((r: any) => { - setBellOnComplete(!!r?.config?.display?.bell_on_complete) + const display = r?.config?.display ?? {} + setBellOnComplete(!!display?.bell_on_complete) + setCompact(!!display?.tui_compact) + setStatusBar(display?.tui_statusbar !== false) + setDetailsMode(resolveDetailsMode(display)) }) }, [rpc, sid]) @@ -635,7 +621,11 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity('MCP reloaded after config change') }) rpc('config.get', { key: 'full' }).then((cfg: any) => { - setBellOnComplete(!!cfg?.config?.display?.bell_on_complete) + const display = cfg?.config?.display ?? {} + setBellOnComplete(!!display?.bell_on_complete) + setCompact(!!display?.tui_compact) + setStatusBar(display?.tui_statusbar !== false) + setDetailsMode(resolveDetailsMode(display)) }) } else if (!configMtimeRef.current && next) { configMtimeRef.current = next @@ -681,7 +671,6 @@ export function App({ gw }: { gw: GatewayClient }) { setInfo(null) setHistoryItems([]) setMessages([]) - setPastes([]) setActivity([]) setBgTasks(new Set()) setUsage(ZERO) @@ -825,55 +814,6 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Paste pipeline ─────────────────────────────────────────────── - const resolvePasteTokens = useCallback( - (text: string) => { - const byId = new Map(pastes.map(p => [p.id, p])) - const missingIds = new Set() - const usedIds = new Set() - let redactions = 0 - - const resolved = text.replace(PASTE_TOKEN_RE, (_m, rawId: string) => { - const id = parseInt(rawId, 10) - const paste = byId.get(id) - - if (!paste) { - missingIds.add(id) - - return `[missing paste:${id}]` - } - - usedIds.add(id) - const cleaned = redactSecrets(paste.text) - redactions += cleaned.redactions - - if (paste.mode === 'inline') { - return cleaned.text - } - - const lang = paste.kind === 'code' ? 'text' : '' - const lines = cleaned.text.split('\n') - - if (paste.mode === 'excerpt') { - let excerpt = lines.slice(0, EXCERPT.lines).join('\n') - - if (excerpt.length > EXCERPT.chars) { - excerpt = excerpt.slice(0, EXCERPT.chars).trimEnd() + '…' - } - - const truncated = lines.length > EXCERPT.lines || cleaned.text.length > excerpt.length - const tail = truncated ? `\n…[paste #${id} truncated]` : '' - - return `[paste #${id} excerpt]\n\`\`\`${lang}\n${excerpt}${tail}\n\`\`\`` - } - - return `[paste #${id} attached · ${paste.lineCount} lines]\n\`\`\`${lang}\n${cleaned.text}\n\`\`\`` - }) - - return { missingIds: [...missingIds], redactions, text: resolved, usedIds: [...usedIds] } - }, - [pastes] - ) - const paste = useCallback( (quiet = false) => rpc('clipboard.paste', { session_id: sid }).then((r: any) => { @@ -911,70 +851,23 @@ export function App({ gw }: { gw: GatewayClient }) { return null } - const lineCount = cleanedText.split('\n').length - - if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { - return { - cursor: cursor + cleanedText.length, - value: value.slice(0, cursor) + cleanedText + value.slice(cursor) - } + return { + cursor: cursor + cleanedText.length, + value: value.slice(0, cursor) + cleanedText + value.slice(cursor) } - - pasteCounterRef.current++ - const id = pasteCounterRef.current - const mode: PasteMode = 'attach' - const charCount = cleanedText.length - const tokenCount = estimateTokensRough(cleanedText) - const token = pasteTokenLabel({ charCount, id, lineCount, text: cleanedText, tokenCount }) - const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' - const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' - const insert = `${lead}${token}${tail}` - - setPastes(prev => - [ - ...prev, - { - charCount, - createdAt: Date.now(), - id, - kind: classifyPaste(cleanedText), - lineCount, - mode, - text: cleanedText, - tokenCount - } - ].slice(-24) - ) - - pushActivity(`captured ${lineCount} lines · ${fmtK(tokenCount)} tok as #${id} (${mode})`) - - return { cursor: cursor + insert.length, value: value.slice(0, cursor) + insert + value.slice(cursor) } }, - [paste, pushActivity] + [paste] ) // ── Send ───────────────────────────────────────────────────────── const send = (text: string) => { - const payload = resolvePasteTokens(text) - - if (payload.missingIds.length) { - pushActivity(`missing paste token(s): ${payload.missingIds.join(', ')}`, 'warn') - - return - } - - if (payload.redactions > 0) { - pushActivity(`redacted ${payload.redactions} secret-like value(s)`, 'warn') - } - const startSubmit = (displayText: string, submitText: string) => { if (statusTimerRef.current) { clearTimeout(statusTimerRef.current) statusTimerRef.current = null } - inflightPasteIdsRef.current = payload.usedIds setLastUserMsg(text) appendMessage({ role: 'user', text: displayText }) setBusy(true) @@ -983,7 +876,6 @@ export function App({ gw }: { gw: GatewayClient }) { interruptedRef.current = false gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { - inflightPasteIdsRef.current = [] sys(`error: ${e.message}`) setStatus('ready') setBusy(false) @@ -1000,14 +892,14 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`detected file: ${r.name}`) } - startSubmit(r.text || text, r.text || payload.text) + startSubmit(r.text || text, r.text || text) return } - startSubmit(text, payload.text) + startSubmit(text, text) }) - .catch(() => startSubmit(text, payload.text)) + .catch(() => startSubmit(text, text)) } const shellExec = (cmd: string) => { @@ -1148,14 +1040,6 @@ export function App({ gw }: { gw: GatewayClient }) { return } - const { missingIds } = resolvePasteTokens(full) - - if (missingIds.length) { - pushActivity(`missing paste token(s): ${missingIds.join(', ')}`, 'warn') - - return - } - clearInput() const editIdx = queueEditRef.current @@ -1198,7 +1082,7 @@ export function App({ gw }: { gw: GatewayClient }) { send(full) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [appendMessage, busy, enqueue, gw, pushHistory, resolvePasteTokens, sid] + [appendMessage, busy, enqueue, gw, pushHistory, sid] ) // ── Input handling ─────────────────────────────────────────────── @@ -1273,6 +1157,23 @@ export function App({ gw }: { gw: GatewayClient }) { return } + if (key.wheelUp) { + scrollRef.current?.scrollBy(-WHEEL_SCROLL_STEP) + return + } + + if (key.wheelDown) { + scrollRef.current?.scrollBy(WHEEL_SCROLL_STEP) + return + } + + if (key.pageUp || key.pageDown) { + const viewport = scrollRef.current?.getViewportHeight() ?? Math.max(6, (stdout?.rows ?? 24) - 8) + const step = Math.max(4, viewport - 2) + scrollRef.current?.scrollBy(key.pageUp ? -step : step) + return + } + if (key.tab && completions.length) { const row = completions[compIdx] @@ -1351,6 +1252,11 @@ export function App({ gw }: { gw: GatewayClient }) { statusTimerRef.current = null setStatus('ready') }, 1500) + } else if (hasSelection) { + const copied = selection.copySelection() + if (copied) { + sys('copied selection') + } } else if (input || inputBuf.length) { clearIn() } else { @@ -1375,16 +1281,6 @@ export function App({ gw }: { gw: GatewayClient }) { return } - if (ctrl(key, ch, 't')) { - if (hasAnyThinking) { - setThinkingMode(mode => (mode === 'collapsed' ? 'truncated' : mode === 'truncated' ? 'full' : 'collapsed')) - } else { - sys('no thinking available') - } - - return - } - if (ctrl(key, ch, 'b')) { if (voiceRecording) { setVoiceRecording(false) @@ -1747,11 +1643,6 @@ export function App({ gw }: { gw: GatewayClient }) { setReasoning('') setStreaming('') - if (inflightPasteIdsRef.current.length) { - setPastes(prev => prev.filter(paste => !inflightPasteIdsRef.current.includes(paste.id))) - inflightPasteIdsRef.current = [] - } - if (!wasInterrupted) { appendMessage({ role: 'assistant', @@ -1790,7 +1681,6 @@ export function App({ gw }: { gw: GatewayClient }) { } case 'error': - inflightPasteIdsRef.current = [] idle() setReasoning('') turnToolsRef.current = [] @@ -1869,6 +1759,11 @@ export function App({ gw }: { gw: GatewayClient }) { sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) } + sections.push({ + title: 'TUI', + rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']] + }) + sections.push({ title: 'Hotkeys', rows: HOTKEYS }) panel('Commands', sections) @@ -1929,6 +1824,7 @@ export function App({ gw }: { gw: GatewayClient }) { const mode = arg.trim().toLowerCase() setCompact(current => { const next = mode === 'on' ? true : mode === 'off' ? false : !current + rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) return next @@ -1936,7 +1832,46 @@ export function App({ gw }: { gw: GatewayClient }) { } return true + + case 'details': + + case 'detail': + if (!arg) { + rpc('config.get', { key: 'details_mode' }) + .then((r: any) => { + const mode = parseDetailsMode(r?.value) ?? detailsMode + setDetailsMode(mode) + sys(`details: ${mode}`) + }) + .catch(() => sys(`details: ${detailsMode}`)) + + return true + } + + { + const mode = arg.trim().toLowerCase() + if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { + sys('usage: /details [hidden|collapsed|expanded|cycle]') + return true + } + + const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(detailsMode) : (mode as DetailsMode) + setDetailsMode(next) + rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) + sys(`details: ${next}`) + } + + return true + case 'copy': { + if (!arg && hasSelection) { + const copied = selection.copySelection() + if (copied) { + sys('copied selection') + return true + } + } + const all = messages.filter(m => m.role === 'assistant') if (arg && Number.isNaN(parseInt(arg, 10))) { @@ -1966,71 +1901,7 @@ export function App({ gw }: { gw: GatewayClient }) { return true } - if (arg === 'list') { - if (!pastes.length) { - sys('no text pastes') - } else { - panel('Paste Shelf', [ - { - rows: pastes.map( - p => - [ - `#${p.id} ${p.mode}`, - `${p.lineCount}L · ${p.kind} · ${compactPreview(p.text, 60) || '(empty)'}` - ] as [string, string] - ) - } - ]) - } - - return true - } - - if (arg === 'clear') { - setPastes([]) - setInput(v => stripTokens(v, PASTE_TOKEN_RE)) - setInputBuf(prev => prev.map(l => stripTokens(l, PASTE_TOKEN_RE)).filter(Boolean)) - pushActivity('cleared paste shelf') - - return true - } - - if (arg.startsWith('drop ')) { - const id = parseInt(arg.split(/\s+/)[1] ?? '-1', 10) - - if (!id || !pastes.some(p => p.id === id)) { - sys('usage: /paste drop ') - - return true - } - - const re = new RegExp(`\\s*\\[\\[paste:${id}(?:[^\\n]*?)\\]\\]\\s*`, 'g') - setPastes(prev => prev.filter(p => p.id !== id)) - setInput(v => stripTokens(v, re)) - setInputBuf(prev => prev.map(l => stripTokens(l, re)).filter(Boolean)) - pushActivity(`dropped paste #${id}`) - - return true - } - - if (arg.startsWith('mode ')) { - const [, rawId, rawMode] = arg.split(/\s+/) - const id = parseInt(rawId ?? '-1', 10) - const mode = rawMode as PasteMode - - if (!id || !['attach', 'excerpt', 'inline'].includes(mode) || !pastes.some(p => p.id === id)) { - sys('usage: /paste mode ') - - return true - } - - setPastes(prev => prev.map(p => (p.id === id ? { ...p, mode } : p))) - pushActivity(`paste #${id} mode → ${mode}`) - - return true - } - - sys('usage: /paste [list|mode |drop |clear]') + sys('usage: /paste') return true case 'logs': { @@ -2043,8 +1914,15 @@ export function App({ gw }: { gw: GatewayClient }) { case 'statusbar': case 'sb': + if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { + sys('usage: /statusbar [on|off|toggle]') + return true + } + setStatusBar(current => { - const next = !current + const mode = arg.trim().toLowerCase() + const next = mode === 'on' ? true : mode === 'off' ? false : !current + rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) return next @@ -2843,18 +2721,20 @@ export function App({ gw }: { gw: GatewayClient }) { [ catalog, compact, + detailsMode, guardBusySessionSwitch, gw, + hasSelection, lastUserMsg, maybeWarn, messages, newSession, page, panel, - pastes, pushActivity, rpc, resetVisibleHistory, + selection, send, sid, statusBar, @@ -2943,249 +2823,266 @@ export function App({ gw }: { gw: GatewayClient }) { const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` const hasReasoning = Boolean(reasoning.trim()) - - const showProgressArea = Boolean(busy || tools.length || turnTrail.length || hasReasoning) + const showProgressArea = + detailsMode === 'hidden' + ? activity.some(i => i.tone !== 'info') + : Boolean(busy || tools.length || turnTrail.length || hasReasoning || activity.length) const showStreamingArea = Boolean(streaming) + const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end) // ── Render ─────────────────────────────────────────────────────── return ( - - {historyItems.map((m, i) => ( - - {m.kind === 'intro' && m.info ? ( - - - - - ) : m.kind === 'panel' && m.panelData ? ( - - ) : ( - - )} - - ))} + + + + + {virtualHistory.topSpacer > 0 ? : null} - - {showProgressArea && ( - - - - )} + {visibleHistory.map(row => ( + + {row.msg.kind === 'intro' && row.msg.info ? ( + + + + + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( + + ) : ( + + )} + + ))} - {showStreamingArea && ( - - - - )} + {virtualHistory.bottomSpacer > 0 ? : null} - {clarify && ( - - answerClarify('')} - req={clarify} - t={theme} - /> - - )} - - {approval && ( - - { - rpc('approval.respond', { choice, session_id: sid }).then(r => { - if (!r) { - return - } - - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }) - }} - req={approval} - t={theme} - /> - - )} - - {sudo && ( - - { - rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - - setSudo(null) - setStatus('running…') - }) - }} - t={theme} - /> - - )} - - {secret && ( - - { - rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { - if (!r) { - return - } - - setSecret(null) - setStatus('running…') - }) - }} - sub={`for ${secret.envVar}`} - t={theme} - /> - - )} - - {picker && ( - - setPicker(false)} onSelect={resumeById} t={theme} /> - - )} - - {modelPicker && ( - - setModelPicker(false)} - onSelect={value => { - setModelPicker(false) - slash(`/model ${value}`) - }} - sessionId={sid} - t={theme} - /> - - )} - - - - - - {bgTasks.size > 0 && ( - - {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running - - )} - - - - {statusBar && ( - - )} - - {pager && ( - - {pager.title && ( - - - {pager.title} - + {showStreamingArea && ( + + )} - - {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( - {line} - ))} - - - - {pager.offset + pagerPageSize < pager.lines.length - ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` - : `end · q to close (${pager.lines.length} lines)`} - - - )} - - {!isBlocked && ( - - {inputBuf.map((line, i) => ( - - - {i === 0 ? `${theme.brand.prompt} ` : ' '} - - - {line || ' '} - - ))} + + + {showProgressArea && ( - - - {inputBuf.length ? ' ' : `${theme.brand.prompt} `} - - - - - - )} + )} - {!!completions.length && ( - - {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { - const active = Math.max(0, compIdx - 8) + i === compIdx + {clarify && ( + + answerClarify('')} + req={clarify} + t={theme} + /> + + )} - return ( - - - {item.display} + {approval && ( + + { + rpc('approval.respond', { choice, session_id: sid }).then(r => { + if (!r) { + return + } + + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }) + }} + req={approval} + t={theme} + /> + + )} + + {sudo && ( + + { + rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + setSudo(null) + setStatus('running…') + }) + }} + t={theme} + /> + + )} + + {secret && ( + + { + rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { + if (!r) { + return + } + + setSecret(null) + setStatus('running…') + }) + }} + sub={`for ${secret.envVar}`} + t={theme} + /> + + )} + + {picker && ( + + setPicker(false)} onSelect={resumeById} t={theme} /> + + )} + + {modelPicker && ( + + setModelPicker(false)} + onSelect={value => { + setModelPicker(false) + slash(`/model ${value}`) + }} + sessionId={sid} + t={theme} + /> + + )} + + + + {bgTasks.size > 0 && ( + + {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running + + )} + + + + {statusBar && ( + + )} + + {pager && ( + + {pager.title && ( + + + {pager.title} - {item.meta ? {item.meta} : null} - - ) - })} - - )} + + )} - {!empty && !sid && ⚕ {status}} + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + + {pager.offset + pagerPageSize < pager.lines.length + ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end · q to close (${pager.lines.length} lines)`} + + + + )} + + {!isBlocked && ( + + {inputBuf.map((line, i) => ( + + + {i === 0 ? `${theme.brand.prompt} ` : ' '} + + + {line || ' '} + + ))} + + + + + {inputBuf.length ? ' ' : `${theme.brand.prompt} `} + + + + + + + )} + + {!!completions.length && ( + + {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { + const active = Math.max(0, compIdx - 8) + i === compIdx + + return ( + + + {item.display} + + {item.meta ? {item.meta} : null} + + ) + })} + + )} + + {!empty && !sid && ⚕ {status}} + - + ) } diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 2ab0e22728..0403135de6 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,42 +1,39 @@ -import { Ansi, Box, Text } from '@hermes/ink' +import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' import { LONG_MSG, ROLE } from '../constants.js' -import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, thinkingPreview, userDisplay } from '../lib/text.js' +import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { Msg, ThinkingMode } from '../types.js' - +import type { DetailsMode, Msg } from '../types.js' import { Md } from './markdown.js' import { ToolTrail } from './thinking.js' export const MessageLine = memo(function MessageLine({ cols, compact, - thinkingMode = 'truncated', + detailsMode = 'collapsed', msg, t }: { cols: number compact?: boolean - thinkingMode?: ThinkingMode + detailsMode?: DetailsMode msg: Msg t: Theme }) { if (msg.kind === 'trail' && msg.tools?.length) { - return ( + return detailsMode === 'hidden' ? null : ( - + ) } if (msg.role === 'tool') { - const preview = compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) - return ( - {preview || '(empty tool result)'} + {compactPreview(hasAnsi(msg.text) ? stripAnsi(msg.text) : msg.text, Math.max(24, cols - 14)) || '(empty tool result)'} ) @@ -44,33 +41,19 @@ export const MessageLine = memo(function MessageLine({ const { body, glyph, prefix } = ROLE[msg.role](t) const thinking = msg.thinking?.replace(/\n/g, ' ').trim() ?? '' - const preview = thinkingPreview(thinking, thinkingMode, Math.min(96, Math.max(32, cols - 18))) - const showThinkingPreview = Boolean(preview && !msg.tools?.length) + const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking)) const content = (() => { - if (msg.kind === 'slash') { - return {msg.text} - } - - if (msg.role !== 'user' && hasAnsi(msg.text)) { - return {msg.text} - } - - if (msg.role === 'assistant') { - return - } + if (msg.kind === 'slash') return {msg.text} + if (msg.role !== 'user' && hasAnsi(msg.text)) return {msg.text} + if (msg.role === 'assistant') return if (msg.role === 'user' && msg.text.length > LONG_MSG && isPasteBackedText(msg.text)) { const [head, ...rest] = userDisplay(msg.text).split('[long message]') - return ( {head} - - - [long message] - - + [long message] {rest.join('')} ) @@ -85,25 +68,16 @@ export const MessageLine = memo(function MessageLine({ marginBottom={msg.role === 'user' ? 1 : 0} marginTop={msg.role === 'user' || msg.kind === 'slash' ? 1 : 0} > - {msg.tools?.length ? ( + {showDetails && ( - + - ) : null} - - {showThinkingPreview && ( - - {'└ '} - {preview} - )} - - - {glyph}{' '} - - + + {glyph}{' '} + {content} diff --git a/ui-tui/src/components/pasteShelf.tsx b/ui-tui/src/components/pasteShelf.tsx deleted file mode 100644 index ca5b934852..0000000000 --- a/ui-tui/src/components/pasteShelf.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Box, Text } from '@hermes/ink' - -import { compactPreview, fmtK } from '../lib/text.js' -import type { Theme } from '../theme.js' -import type { PendingPaste } from '../types.js' - -const TOKEN_RE = /\[\[paste:(\d+)(?:[^\n]*?)\]\]/g - -const modeLabel = { - attach: 'attach', - excerpt: 'excerpt', - inline: 'inline' -} as const - -export function PasteShelf({ draft, pastes, t }: { draft: string; pastes: PendingPaste[]; t: Theme }) { - if (!pastes.length) { - return null - } - - const inDraft = new Set() - - for (const m of draft.matchAll(TOKEN_RE)) { - inDraft.add(parseInt(m[1] ?? '-1', 10)) - } - - return ( - - Paste shelf ({pastes.length}) - {pastes.slice(-4).map(paste => ( - - #{paste.id} {modeLabel[paste.mode]} · {paste.lineCount}L · {fmtK(paste.tokenCount)} tok ·{' '} - {fmtK(paste.charCount)} chars · {paste.kind} - {inDraft.has(paste.id) ? · in draft : ''} - {' · '} - {compactPreview(paste.text, 44) || '(empty)'} - - ))} - {pastes.length > 4 && ( - - …and {pastes.length - 4} more - - )} - - /paste mode {''} {''} · /paste drop {''} · /paste clear - - - ) -} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index edc586e937..8df818811e 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -156,6 +156,55 @@ function cursorLayout(value: string, cursor: number, cols: number) { return { column: col, line } } +function offsetFromPosition(value: string, row: number, col: number, cols: number) { + if (!value.length) { + return 0 + } + + const targetRow = Math.max(0, Math.floor(row)) + const targetCol = Math.max(0, Math.floor(col)) + const w = Math.max(1, cols - 1) + + let line = 0 + let column = 0 + let lastOffset = 0 + + for (const { segment, index } of seg().segment(value)) { + lastOffset = index + + if (segment === '\n') { + if (line === targetRow) { + return index + } + line++ + column = 0 + continue + } + + const sw = Math.max(1, stringWidth(segment)) + + if (column + sw > w) { + if (line === targetRow) { + return index + } + line++ + column = 0 + } + + if (line === targetRow && targetCol <= column + Math.max(0, sw - 1)) { + return index + } + + column += sw + } + + if (targetRow >= line) { + return value.length + } + + return lastOffset +} + // ── Render value with inverse-video cursor ─────────────────────────── function renderWithCursor(value: string, cursor: number) { @@ -283,6 +332,13 @@ export function TextInput({ return renderWithCursor(display, cur) }, [cur, display, focus, placeholder]) + const clickCursor = (e: { localRow?: number; localCol?: number }) => { + if (!focus) return + const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + setCur(next) + curRef.current = next + } + // ── Sync external value changes ────────────────────────────────── useEffect(() => { @@ -512,7 +568,7 @@ export function TextInput({ // ── Render ─────────────────────────────────────────────────────── return ( - + {rendered} ) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 1ed03f8104..b24766ab3e 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -4,6 +4,7 @@ import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { FACES, VERBS } from '../constants.js' import { + compactPreview, formatToolCall, parseToolTrailResultLine, pick, @@ -12,23 +13,21 @@ import { toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool, ActivityItem, ThinkingMode } from '../types.js' +import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] const fmtElapsed = (ms: number) => { const sec = Math.max(0, ms) / 1000 - return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } -// ── Spinner ────────────────────────────────────────────────────────── +// ── Primitives ─────────────────────────────────────────────────────── export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const [spin] = useState(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] - return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } }) @@ -36,15 +35,12 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: useEffect(() => { const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) - return () => clearInterval(id) }, [spin]) return {spin.frames[frame]} } -// ── Detail row ─────────────────────────────────────────────────────── - type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string } function Detail({ color, content, dimColor }: DetailRow) { @@ -56,54 +52,47 @@ function Detail({ color, content, dimColor }: DetailRow) { ) } -// ── Streaming cursor ───────────────────────────────────────────────── - -function StreamCursor({ - color, - dimColor, - streaming = false, - visible = false -}: { - color: string - dimColor?: boolean - streaming?: boolean - visible?: boolean +function StreamCursor({ color, dimColor, streaming = false, visible = false }: { + color: string; dimColor?: boolean; streaming?: boolean; visible?: boolean }) { const [on, setOn] = useState(true) useEffect(() => { const id = setInterval(() => setOn(v => !v), 420) - return () => clearInterval(id) }, []) - return visible ? ( - - {streaming && on ? '▍' : ' '} - - ) : null + return visible ? {streaming && on ? '▍' : ' '} : null } -// ── Thinking (pre-tool fallback) ───────────────────────────────────── +function Chevron({ count, onClick, open, summary, t, title, tone = 'dim' }: { + count?: number; onClick: () => void; open: boolean; summary?: string + t: Theme; title: string; tone?: 'dim' | 'error' | 'warn' +}) { + const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim + + return ( + + + {open ? '▾ ' : '▸ '} + {title}{typeof count === 'number' ? ` (${count})` : ''} + {summary ? · {summary} : null} + + + ) +} + +// ── Thinking ───────────────────────────────────────────────────────── export const Thinking = memo(function Thinking({ - active = false, - mode = 'truncated', - reasoning, - streaming = false, - t + active = false, mode = 'truncated', reasoning, streaming = false, t }: { - active?: boolean - mode?: ThinkingMode - reasoning: string - streaming?: boolean - t: Theme + active?: boolean; mode?: ThinkingMode; reasoning: string; streaming?: boolean; t: Theme }) { const [tick, setTick] = useState(0) useEffect(() => { const id = setInterval(() => setTick(v => v + 1), 1100) - return () => clearInterval(id) }, []) @@ -132,58 +121,44 @@ export const Thinking = memo(function Thinking({ ) }) -// ── ToolTrail (canonical progress block) ───────────────────────────── +// ── ToolTrail ──────────────────────────────────────────────────────── type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string } export const ToolTrail = memo(function ToolTrail({ - busy = false, - thinkingMode = 'truncated', - reasoningActive = false, - reasoning = '', - reasoningStreaming = false, - t, - tools = [], - trail = [], - activity = [] + busy = false, detailsMode = 'collapsed', reasoningActive = false, + reasoning = '', reasoningStreaming = false, t, + tools = [], trail = [], activity = [] }: { - busy?: boolean - thinkingMode?: ThinkingMode - reasoningActive?: boolean - reasoning?: string - reasoningStreaming?: boolean - t: Theme - tools?: ActiveTool[] - trail?: string[] - activity?: ActivityItem[] + busy?: boolean; detailsMode?: DetailsMode; reasoningActive?: boolean + reasoning?: string; reasoningStreaming?: boolean; t: Theme + tools?: ActiveTool[]; trail?: string[]; activity?: ActivityItem[] }) { const [now, setNow] = useState(() => Date.now()) + const [openThinking, setOpenThinking] = useState(false) + const [openTools, setOpenTools] = useState(false) + const [openMeta, setOpenMeta] = useState(false) useEffect(() => { - if (!tools.length) { - return - } - + if (!tools.length) return const id = setInterval(() => setNow(Date.now()), 200) - return () => clearInterval(id) }, [tools.length]) - const reasoningTail = thinkingPreview(reasoning, thinkingMode, THINKING_COT_MAX) + useEffect(() => { + if (detailsMode === 'expanded') { setOpenThinking(true); setOpenTools(true); setOpenMeta(true) } + if (detailsMode === 'hidden') { setOpenThinking(false); setOpenTools(false); setOpenMeta(false) } + }, [detailsMode]) - if (!busy && !trail.length && !tools.length && !activity.length && !reasoningTail && !reasoningActive) { - return null - } + const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX) + + if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) return null + + // ── Build groups + meta ──────────────────────────────────────── const groups: Group[] = [] const meta: DetailRow[] = [] - - const detail = (row: DetailRow) => { - const g = groups.at(-1) - g ? g.details.push(row) : meta.push(row) - } - - // ── trail → groups + details ──────────────────────────────────── + const pushDetail = (row: DetailRow) => (groups.at(-1)?.details ?? meta).push(row) for (const [i, line] of trail.entries()) { const parsed = parseToolTrailResultLine(line) @@ -192,19 +167,12 @@ export const ToolTrail = memo(function ToolTrail({ groups.push({ color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, - details: [], - key: `tr-${i}` + details: [], key: `tr-${i}` + }) + if (parsed.detail) pushDetail({ + color: parsed.mark === '✗' ? t.color.error : t.color.dim, + content: parsed.detail, dimColor: parsed.mark !== '✗', key: `tr-${i}-d` }) - - if (parsed.detail) { - detail({ - color: parsed.mark === '✗' ? t.color.error : t.color.dim, - content: parsed.detail, - dimColor: parsed.mark !== '✗', - key: `tr-${i}-d` - }) - } - continue } @@ -215,112 +183,134 @@ export const ToolTrail = memo(function ToolTrail({ details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], key: `tr-${i}` }) - continue } if (line === 'analyzing tool output…') { - detail({ - color: t.color.dim, - content: groups.length ? ( - <> - {line} - - ) : ( - line - ), - dimColor: true, - key: `tr-${i}` + pushDetail({ + color: t.color.dim, dimColor: true, key: `tr-${i}`, + content: groups.length + ? <> {line} + : line }) - continue } meta.push({ color: t.color.dim, content: line, dimColor: true, key: `tr-${i}` }) } - // ── live tools → groups ───────────────────────────────────────── - for (const tool of tools) { groups.push({ - color: t.color.cornsilk, + color: t.color.cornsilk, key: tool.id, details: [], content: ( <> {formatToolCall(tool.name, tool.context || '')} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} - ), - details: [], - key: tool.id + ) }) } - if (reasoningTail && groups.length) { - detail({ - color: t.color.dim, - content: ( - <> - {reasoningTail} - - - ), - dimColor: true, - key: 'cot' + if (cot && groups.length) { + pushDetail({ + color: t.color.dim, dimColor: true, key: 'cot', + content: <>{cot} }) - } else if (reasoningActive && groups.length && thinkingMode === 'collapsed') { - detail({ - color: t.color.dim, - content: , - dimColor: true, - key: 'cot' + } else if (reasoningActive && groups.length) { + pushDetail({ + color: t.color.dim, dimColor: true, key: 'cot', + content: }) } - // ── activity → meta ───────────────────────────────────────────── - for (const item of activity.slice(-4)) { const glyph = item.tone === 'error' ? '✗' : item.tone === 'warn' ? '!' : '·' const color = item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim - meta.push({ color, content: `${glyph} ${item.text}`, dimColor: item.tone === 'info', key: `a-${item.id}` }) } - // ── render ────────────────────────────────────────────────────── + // ── Derived ──────────────────────────────────────────────────── + + const hasTools = groups.length > 0 + const hasMeta = meta.length > 0 + const hasThinking = !hasTools && (busy || !!cot || reasoningActive) + + // ── Hidden: errors/warnings only ────────────────────────────── + + if (detailsMode === 'hidden') { + const alerts = activity.filter(i => i.tone !== 'info').slice(-2) + return alerts.length ? ( + + {alerts.map(i => ( + + {i.tone === 'error' ? '✗' : '!'} {i.text} + + ))} + + ) : null + } + + // ── Shared render fragments ──────────────────────────────────── + + const thinkingBlock = hasThinking ? ( + busy + ? + : cot + ? + : } dimColor key="cot" /> + ) : null + + const toolBlock = hasTools ? groups.map(g => ( + + + + {g.content} + + {g.details.map(d => )} + + )) : null + + const metaBlock = hasMeta ? meta.map((row, i) => ( + + {i === meta.length - 1 ? '└ ' : '├ '} + {row.content} + + )) : null + + // ── Expanded: flat, no accordions ────────────────────────────── + + if (detailsMode === 'expanded') { + return {thinkingBlock}{toolBlock}{metaBlock} + } + + // ── Collapsed: clickable accordions ──────────────────────────── + + const metaTone: 'dim' | 'error' | 'warn' = + activity.some(i => i.tone === 'error') ? 'error' + : activity.some(i => i.tone === 'warn') ? 'warn' : 'dim' return ( - {busy && !groups.length && ( - - )} - {!busy && !groups.length && reasoningTail && ( - + {hasThinking && ( + <> + setOpenThinking(v => !v)} open={openThinking} summary={cot ? compactPreview(cot, 56) : busy ? 'running…' : ''} t={t} title="Thinking" /> + {openThinking && thinkingBlock} + )} - {groups.map(g => ( - - - - {g.content} - + {hasTools && ( + <> + setOpenTools(v => !v)} open={openTools} t={t} title="Tool calls" /> + {openTools && toolBlock} + + )} - {g.details.map(d => ( - - ))} - - ))} - - {meta.map((row, i) => ( - - {i === meta.length - 1 ? '└ ' : '├ '} - {row.content} - - ))} + {hasMeta && ( + <> + setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" tone={metaTone} /> + {openMeta && metaBlock} + + )} ) }) diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts index 9e7cac9999..9e8cb5a2ba 100644 --- a/ui-tui/src/constants.ts +++ b/ui-tui/src/constants.ts @@ -24,7 +24,6 @@ export const HOTKEYS: [string, string][] = [ ['Ctrl+D', 'exit'], ['Ctrl+G', 'open $EDITOR for prompt'], ['Ctrl+L', 'new session (clear)'], - ['Ctrl+T', 'cycle thinking detail'], ['Alt+V / /paste', 'paste clipboard image'], ['Tab', 'apply completion'], ['↑/↓', 'completions / queue edit / history'], diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 2c98c64e04..a35f3c417b 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -1,6 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process' import { EventEmitter } from 'node:events' -import { resolve } from 'node:path' +import { delimiter, resolve } from 'node:path' import { createInterface } from 'node:readline' const MAX_GATEWAY_LOG_LINES = 200 @@ -55,6 +55,9 @@ export class GatewayClient extends EventEmitter { const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') const python = process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python') const cwd = process.env.HERMES_CWD || root + const env = { ...process.env } + const pyPath = (env.PYTHONPATH ?? '').trim() + env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root this.ready = false this.pendingExit = undefined this.stdoutRl?.close() @@ -81,6 +84,7 @@ export class GatewayClient extends EventEmitter { this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, + env, stdio: ['pipe', 'pipe', 'pipe'] }) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts new file mode 100644 index 0000000000..877fde5d7f --- /dev/null +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -0,0 +1,117 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from 'react' + +import type { ScrollBoxHandle } from '@hermes/ink' + +const ESTIMATE = 4 +const OVERSCAN = 40 +const MAX_MOUNTED = 260 +const COLD_START = 40 +const QUANTUM = 8 + +const upperBound = (arr: number[], target: number) => { + let lo = 0, hi = arr.length + while (lo < hi) { + const mid = (lo + hi) >> 1 + arr[mid]! <= target ? lo = mid + 1 : hi = mid + } + return lo +} + +export function useVirtualHistory( + scrollRef: RefObject, + items: readonly { key: string }[], + { estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {} +) { + const nodes = useRef(new Map()) + const heights = useRef(new Map()) + const refs = useRef(new Map void>()) + const [ver, setVer] = useState(0) + + useSyncExternalStore( + useCallback( + (cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}), + [scrollRef] + ), + () => { + const s = scrollRef.current + if (!s) return NaN + const b = Math.floor(s.getScrollTop() / QUANTUM) + return s.isSticky() ? -b - 1 : b + }, + () => NaN + ) + + useEffect(() => { + const keep = new Set(items.map(i => i.key)) + let dirty = false + for (const k of heights.current.keys()) { + if (!keep.has(k)) { + heights.current.delete(k) + nodes.current.delete(k) + refs.current.delete(k) + dirty = true + } + } + if (dirty) setVer(v => v + 1) + }, [items]) + + const offsets = useMemo(() => { + const out = new Array(items.length + 1).fill(0) + for (let i = 0; i < items.length; i++) + out[i + 1] = out[i]! + Math.max(1, Math.floor(heights.current.get(items[i]!.key) ?? estimate)) + return out + }, [estimate, items, ver]) + + const total = offsets[items.length] ?? 0 + const top = Math.max(0, scrollRef.current?.getScrollTop() ?? 0) + const vp = Math.max(0, scrollRef.current?.getViewportHeight() ?? 0) + const sticky = scrollRef.current?.isSticky() ?? true + + let start = 0, end = items.length + + if (items.length > 0) { + if (vp <= 0) { + start = Math.max(0, items.length - coldStartCount) + } else { + start = Math.max(0, Math.min(items.length - 1, upperBound(offsets, Math.max(0, top - overscan)) - 1)) + end = Math.max(start + 1, Math.min(items.length, upperBound(offsets, top + vp + overscan))) + } + } + + if (end - start > maxMounted) { + sticky + ? (start = Math.max(0, end - maxMounted)) + : (end = Math.min(items.length, start + maxMounted)) + } + + const measureRef = useCallback((key: string) => { + let fn = refs.current.get(key) + if (!fn) { + fn = (el: any) => el ? nodes.current.set(key, el) : nodes.current.delete(key) + refs.current.set(key, fn) + } + return fn + }, []) + + useLayoutEffect(() => { + let dirty = false + for (let i = start; i < end; i++) { + const k = items[i]?.key + if (!k) continue + const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0) + if (h > 0 && heights.current.get(k) !== h) { + heights.current.set(k, h) + dirty = true + } + } + if (dirty) setVer(v => v + 1) + }, [end, items, start]) + + return { + start, + end, + topSpacer: offsets[start] ?? 0, + bottomSpacer: Math.max(0, total - (offsets[end] ?? total)), + measureRef + } +} diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index e8d94e64c2..aac00d667a 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -33,6 +33,7 @@ export interface Msg { } export type Role = 'assistant' | 'system' | 'tool' | 'user' +export type DetailsMode = 'hidden' | 'collapsed' | 'expanded' export type ThinkingMode = 'collapsed' | 'truncated' | 'full' export interface SessionInfo { @@ -78,20 +79,6 @@ export interface PanelSection { title?: string } -export type PasteKind = 'code' | 'log' | 'text' -export type PasteMode = 'attach' | 'excerpt' | 'inline' - -export interface PendingPaste { - charCount: number - createdAt: number - id: number - kind: PasteKind - lineCount: number - mode: PasteMode - text: string - tokenCount: number -} - export interface SlashCatalog { canon: Record categories: SlashCategory[] diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 81faab32ea..d6ecb7f61f 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -17,6 +17,8 @@ declare module '@hermes/ink' { readonly tab: boolean readonly pageUp: boolean readonly pageDown: boolean + readonly wheelUp: boolean + readonly wheelDown: boolean readonly home: boolean readonly end: boolean readonly [key: string]: boolean @@ -44,8 +46,21 @@ declare module '@hermes/ink' { readonly cleanup: () => void } + export type ScrollBoxHandle = { + readonly scrollTo: (y: number) => void + readonly scrollBy: (dy: number) => void + readonly scrollToBottom: () => void + readonly getScrollTop: () => number + readonly getViewportHeight: () => number + readonly isSticky: () => boolean + readonly subscribe: (listener: () => void) => () => void + } + export const Box: React.ComponentType + export const AlternateScreen: React.ComponentType export const Ansi: React.ComponentType + export const NoSelect: React.ComponentType + export const ScrollBox: React.ComponentType export const Text: React.ComponentType export const TextInput: React.ComponentType export const stringWidth: (s: string) => number @@ -54,6 +69,20 @@ declare module '@hermes/ink' { export function useApp(): { readonly exit: (error?: Error) => void } export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void + export function useSelection(): { + readonly copySelection: () => string + readonly copySelectionNoClear: () => string + readonly clearSelection: () => void + readonly hasSelection: () => boolean + readonly getState: () => unknown + readonly subscribe: (cb: () => void) => () => void + readonly shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void + readonly shiftSelection: (dRow: number, minRow: number, maxRow: number) => void + readonly moveFocus: (move: unknown) => void + readonly captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void + readonly setSelectionBgColor: (color: string) => void + } + export function useHasSelection(): boolean export function useStdout(): { readonly stdout?: NodeJS.WriteStream } export function useTerminalFocus(): boolean export function useDeclaredCursor(args: { From 35dbb1da3fa526fb6e89e8886329f304e5e6b284 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 21:22:44 -0500 Subject: [PATCH 099/157] chore: uptick --- hermes_cli/main.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fb4423220e..a2c797fd4f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -725,14 +725,19 @@ def _print_tui_exit_summary(session_id: Optional[str]) -> None: ) +def _tui_deps_ready(root: Path) -> bool: + """Nix and local dev both need file: workspace @hermes/ink under node_modules.""" + return (root / "node_modules" / "@hermes" / "ink" / "package.json").is_file() + + def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: """Directory whose dist/entry.js we should run: HERMES_TUI_DIR first, else repo ui-tui.""" env = os.environ.get("HERMES_TUI_DIR") if env: p = Path(env) - if (p / "dist" / "entry.js").exists(): + if (p / "dist" / "entry.js").exists() and _tui_deps_ready(p): return p - if (tui_dir / "dist" / "entry.js").exists(): + if (tui_dir / "dist" / "entry.js").exists() and _tui_deps_ready(tui_dir): return tui_dir return None @@ -784,15 +789,17 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: sys.exit(1) return path - # pre-built dist (nix / HERMES_TUI_DIR) needs no npm at all. + # pre-built dist + node_modules (nix / full HERMES_TUI_DIR) skips npm. if not tui_dev: ext_dir = os.environ.get("HERMES_TUI_DIR") - if ext_dir and (Path(ext_dir) / "dist" / "entry.js").exists(): - node = _node_bin("node") - return [node, str(Path(ext_dir) / "dist" / "entry.js")], Path(ext_dir) + if ext_dir: + p = Path(ext_dir) + if (p / "dist" / "entry.js").exists() and _tui_deps_ready(p): + node = _node_bin("node") + return [node, str(p / "dist" / "entry.js")], p npm = _node_bin("npm") - if not (tui_dir / "node_modules").exists(): + if not _tui_deps_ready(tui_dir): print("Installing TUI dependencies…") result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], From bbc7316007fb24151e2e9eff610a77552070bf84 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 13 Apr 2026 21:46:08 -0500 Subject: [PATCH 100/157] feat: add cur cwd --- ui-tui/src/app.tsx | 59 ++++++++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 28 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index af8f607867..b6cb03cb3b 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -91,6 +91,12 @@ const nextDetailsMode = (m: DetailsMode): DetailsMode => const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) +const shortCwd = (cwd: string, max = 28) => { + const home = process.env.HOME + const path = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd + return path.length <= max ? path : `…${path.slice(-(max - 1))}` +} + const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' @@ -207,6 +213,7 @@ function fmtDuration(ms: number) { } function StatusRule({ + cwdLabel, cols, status, statusColor, @@ -217,6 +224,7 @@ function StatusRule({ voiceLabel, t }: { + cwdLabel: string cols: number status: string statusColor: string @@ -239,37 +247,30 @@ function StatusRule({ const pctLabel = pct != null ? `${pct}%` : '' const bar = usage.context_max ? ctxBar(pct) : '' - const segs = [ - status, - model, - ctxLabel, - bar ? `[${bar}]` : '', - pctLabel, - durationLabel || '', - voiceLabel || '', - bgCount > 0 ? `${bgCount} bg` : '' - ].filter(Boolean) - - const inner = segs.join(' │ ') - const pad = Math.max(0, cols - inner.length - 5) + const leftWidth = Math.max(12, cols - cwdLabel.length - 3) return ( - - {'─ '} - {status} - │ {model} - {ctxLabel ? │ {ctxLabel} : null} - {bar ? ( - - {' │ '} - [{bar}] {pctLabel} + + + + {'─ '} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} + {bar ? ( + + {' │ '} + [{bar}] {pctLabel} + + ) : null} + {durationLabel ? │ {durationLabel} : null} + {voiceLabel ? │ {voiceLabel} : null} + {bgCount > 0 ? │ {bgCount} bg : null} - ) : null} - {durationLabel ? │ {durationLabel} : null} - {voiceLabel ? │ {voiceLabel} : null} - {bgCount > 0 ? │ {bgCount} bg : null} - {' ' + '─'.repeat(pad)} - + + + {cwdLabel} + ) } @@ -2821,6 +2822,7 @@ export function App({ gw }: { gw: GatewayClient }) { const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : '' const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` + const cwdLabel = shortCwd(info?.cwd || process.env.HERMES_CWD || process.cwd()) const hasReasoning = Boolean(reasoning.trim()) const showProgressArea = @@ -2998,6 +3000,7 @@ export function App({ gw }: { gw: GatewayClient }) { Date: Mon, 13 Apr 2026 22:39:03 -0500 Subject: [PATCH 101/157] fix: pasting --- ui-tui/src/__tests__/text.test.ts | 22 +++------------- ui-tui/src/app.tsx | 42 +++++++++++++++++++++++++++---- ui-tui/src/lib/text.ts | 27 ++++---------------- 3 files changed, 46 insertions(+), 45 deletions(-) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 904e44ec2a..181b96b43f 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -78,24 +78,10 @@ describe('edgePreview', () => { describe('pasteTokenLabel', () => { it('builds readable long-paste labels with counts', () => { - expect( - pasteTokenLabel({ - charCount: 1000, - id: 7, - lineCount: 250, - text: 'Vampire Bondage ropes slipped from her neck, still stained with blood', - tokenCount: 250 - }) - ).toContain('[[paste:7 ') - expect( - pasteTokenLabel({ - charCount: 1000, - id: 7, - lineCount: 250, - text: 'Vampire Bondage ropes slipped from her neck, still stained with blood', - tokenCount: 250 - }) - ).toContain('[250 lines · 250 tok · 1K chars]') + const label = pasteTokenLabel('Vampire Bondage ropes slipped from her neck, still stained with blood', 250) + expect(label.startsWith('[[ ')).toBe(true) + expect(label).toContain('[250 lines]') + expect(label.endsWith(' ]]')).toBe(true) }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index b6cb03cb3b..c329057c7e 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -40,6 +40,7 @@ import { hasInterpolation, isToolTrailResultLine, isTransientTrailLine, + pasteTokenLabel, pick, sameToolTrailGroup, stripTrailingPasteNewlines, @@ -66,10 +67,12 @@ import type { const PLACEHOLDER = pick(PLACEHOLDERS) const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() +const LARGE_PASTE = { chars: 8000, lines: 80 } const MAX_HISTORY = 800 const REASONING_PULSE_MS = 700 const WHEEL_SCROLL_STEP = 3 const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()) +const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] @@ -90,6 +93,7 @@ const nextDetailsMode = (m: DetailsMode): DetailsMode => // ── Pure helpers ───────────────────────────────────────────────────── const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) +type PasteSnippet = { label: string; text: string } const shortCwd = (cwd: string, max = 28) => { const home = process.env.HOME @@ -339,6 +343,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [reasoningStreaming, setReasoningStreaming] = useState(false) const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') + const [pasteSnips, setPasteSnips] = useState([]) const [streaming, setStreaming] = useState('') const [turnTrail, setTurnTrail] = useState([]) const [bgTasks, setBgTasks] = useState>(new Set()) @@ -672,6 +677,7 @@ export function App({ gw }: { gw: GatewayClient }) { setInfo(null) setHistoryItems([]) setMessages([]) + setPasteSnips([]) setActivity([]) setBgTasks(new Set()) setUsage(ZERO) @@ -687,6 +693,7 @@ export function App({ gw }: { gw: GatewayClient }) { setHistoryItems(info ? [introMsg(info)] : []) setInfo(info) setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO) + setPasteSnips([]) setActivity([]) setLastUserMsg('') turnToolsRef.current = [] @@ -852,9 +859,25 @@ export function App({ gw }: { gw: GatewayClient }) { return null } + const lineCount = cleanedText.split('\n').length + + if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { + return { + cursor: cursor + cleanedText.length, + value: value.slice(0, cursor) + cleanedText + value.slice(cursor) + } + } + + const label = pasteTokenLabel(cleanedText, lineCount) + const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' + const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' + const insert = `${lead}${label}${tail}` + + setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32)) + return { - cursor: cursor + cleanedText.length, - value: value.slice(0, cursor) + cleanedText + value.slice(cursor) + cursor: cursor + insert.length, + value: value.slice(0, cursor) + insert + value.slice(cursor) } }, [paste] @@ -863,6 +886,15 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Send ───────────────────────────────────────────────────────── const send = (text: string) => { + const expandPasteSnips = (value: string) => { + const byLabel = new Map() + for (const item of pasteSnips) { + const list = byLabel.get(item.label) + list ? list.push(item.text) : byLabel.set(item.label, [item.text]) + } + return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) + } + const startSubmit = (displayText: string, submitText: string) => { if (statusTimerRef.current) { clearTimeout(statusTimerRef.current) @@ -893,14 +925,14 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity(`detected file: ${r.name}`) } - startSubmit(r.text || text, r.text || text) + startSubmit(r.text || text, expandPasteSnips(r.text || text)) return } - startSubmit(text, text) + startSubmit(text, expandPasteSnips(text)) }) - .catch(() => startSubmit(text, text)) + .catch(() => startSubmit(text, expandPasteSnips(text))) } const shellExec = (cmd: string) => { diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 9c67b1e372..ba1880ed3c 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -59,35 +59,18 @@ export const edgePreview = (s: string, head = 16, tail = 28) => { return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` } -export const pasteTokenLabel = ({ - charCount, - id, - lineCount, - text, - tokenCount -}: { - charCount: number - id: number - lineCount: number - text: string - tokenCount: number -}) => { +export const pasteTokenLabel = (text: string, lineCount: number) => { const preview = edgePreview(text) - const counts = `[${fmtK(lineCount)} lines · ${fmtK(tokenCount)} tok · ${fmtK(charCount)} chars]` if (!preview) { - return `[[paste:${id} ${counts}]]` - } - - const one = text.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]') - - if (one.length === preview.length) { - return `[[paste:${id} ${preview} ${counts}]]` + return `[[ [${fmtK(lineCount)} lines] ]]` } const [head = preview, tail = ''] = preview.split('.. ', 2) - return `[[paste:${id} ${head.trimEnd()}.. ${counts} .. ${tail.trimStart()}]]` + return tail + ? `[[ ${head.trimEnd()}.. [${fmtK(lineCount)} lines] .. ${tail.trimStart()} ]]` + : `[[ ${preview} [${fmtK(lineCount)} lines] ]]` } export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => { From dd2b0b47758eb672e798196f3101995a5dd07948 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 11:53:55 -0500 Subject: [PATCH 102/157] chore: uptick --- ui-tui/src/app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 03ae3b57e0..e7c1648181 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -3227,7 +3227,7 @@ export function App({ gw }: { gw: GatewayClient }) { )} {!isBlocked && ( - + {inputBuf.map((line, i) => ( From 7aed09e1ba7ca1e34ddbcf7d5fb7bf1b3787880c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 12:07:29 -0500 Subject: [PATCH 103/157] fix: ctrlc --- ui-tui/src/app.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e7c1648181..13d00834ec 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1403,7 +1403,13 @@ export function App({ gw }: { gw: GatewayClient }) { } if (ctrl(key, ch, 'c')) { - if (busy && sid) { + if (hasSelection) { + const copied = selection.copySelection() + + if (copied) { + sys('copied selection') + } + } else if (busy && sid) { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) const partial = (streaming || buf.current).trimStart() @@ -1423,12 +1429,6 @@ export function App({ gw }: { gw: GatewayClient }) { statusTimerRef.current = null setStatus('ready') }, 1500) - } else if (hasSelection) { - const copied = selection.copySelection() - - if (copied) { - sys('copied selection') - } } else if (input || inputBuf.length) { clearIn() } else { From 9804aa7443cc1c1e4a427cde34834f7a7ab9e36a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 12:50:22 -0500 Subject: [PATCH 104/157] fix: scrolling while selecting --- ui-tui/src/app.tsx | 53 ++++++++++++++++++++++++++++++-- ui-tui/src/types/hermes-ink.d.ts | 2 ++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 13d00834ec..86f5c7a2e2 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -558,6 +558,53 @@ export function App({ gw }: { gw: GatewayClient }) { const virtualHistory = useVirtualHistory(scrollRef, virtualRows) + const scrollWithSelection = useCallback( + (delta: number) => { + const s = scrollRef.current + const sel = selection.getState() as + | { anchor?: { row: number }; focus?: { row: number }; isDragging?: boolean } + | null + + if (!s || !sel?.anchor || !sel.focus) { + s?.scrollBy(delta) + return + } + + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 + + if (sel.anchor.row < top || sel.anchor.row > bottom) { + s.scrollBy(delta) + return + } + + if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { + s.scrollBy(delta) + return + } + + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + const cur = s.getScrollTop() + s.getPendingDelta() + const actual = Math.max(0, Math.min(max, cur + delta)) - cur + + if (actual === 0) { + return + } + + if (actual > 0) { + selection.captureScrolledRows(top, top + actual - 1, 'above') + sel.isDragging ? selection.shiftAnchor(-actual, top, bottom) : selection.shiftSelection(-actual, top, bottom) + } else { + const amount = -actual + selection.captureScrolledRows(bottom - amount + 1, bottom, 'below') + sel.isDragging ? selection.shiftAnchor(amount, top, bottom) : selection.shiftSelection(amount, top, bottom) + } + + s.scrollBy(delta) + }, + [selection] + ) + // ── Resize RPC ─────────────────────────────────────────────────── useEffect(() => { @@ -1326,13 +1373,13 @@ export function App({ gw }: { gw: GatewayClient }) { } if (key.wheelUp) { - scrollRef.current?.scrollBy(-WHEEL_SCROLL_STEP) + scrollWithSelection(-WHEEL_SCROLL_STEP) return } if (key.wheelDown) { - scrollRef.current?.scrollBy(WHEEL_SCROLL_STEP) + scrollWithSelection(WHEEL_SCROLL_STEP) return } @@ -1340,7 +1387,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (key.pageUp || key.pageDown) { const viewport = scrollRef.current?.getViewportHeight() ?? Math.max(6, (stdout?.rows ?? 24) - 8) const step = Math.max(4, viewport - 2) - scrollRef.current?.scrollBy(key.pageUp ? -step : step) + scrollWithSelection(key.pageUp ? -step : step) return } diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index 6b3001a855..d144656b33 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -53,7 +53,9 @@ declare module '@hermes/ink' { readonly scrollToBottom: () => void readonly getScrollTop: () => number readonly getPendingDelta: () => number + readonly getScrollHeight: () => number readonly getViewportHeight: () => number + readonly getViewportTop: () => number readonly isSticky: () => boolean readonly subscribe: (listener: () => void) => () => void } From 52c11d172a49c520e04512d4008bb8c931429755 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 14:34:33 -0500 Subject: [PATCH 105/157] feat: add scrollbar and fix selection on scroll --- .../hermes-ink/src/ink/components/App.tsx | 32 +++ .../hermes-ink/src/ink/components/Box.tsx | 162 ++++++++------ .../src/ink/events/event-handlers.ts | 13 +- .../hermes-ink/src/ink/events/mouse-event.ts | 18 ++ .../packages/hermes-ink/src/ink/hit-test.ts | 46 ++++ ui-tui/packages/hermes-ink/src/ink/ink.tsx | 41 +++- ui-tui/src/app.tsx | 207 +++++++++++++----- ui-tui/src/components/textInput.tsx | 1 + ui-tui/src/components/thinking.tsx | 1 + ui-tui/src/hooks/useVirtualHistory.ts | 2 + 10 files changed, 397 insertions(+), 126 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index d288d28bad..3a0381a729 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -5,6 +5,7 @@ import { logForDebugging } from '../../utils/debug.js' import { stopCapturingEarlyInput } from '../../utils/earlyInput.js' import { isMouseClicksDisabled } from '../../utils/fullscreen.js' import { logError } from '../../utils/log.js' +import type { DOMElement } from '../dom.js' import { EventEmitter } from '../events/emitter.js' import { InputEvent } from '../events/input-event.js' import { TerminalFocusEvent } from '../events/terminal-focus-event.js' @@ -67,6 +68,9 @@ type Props = { // No-op (returns false) outside fullscreen mode (Ink.dispatchClick // gates on altScreenActive). readonly onClickAt: (col: number, row: number) => boolean + readonly onMouseDownAt: (col: number, row: number, button: number) => DOMElement | undefined + readonly onMouseUpAt: (target: DOMElement, col: number, row: number, button: number) => void + readonly onMouseDragAt: (target: DOMElement, col: number, row: number, button: number) => void // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over // DOM elements. Called for mode-1003 motion events with no button held. // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). @@ -155,6 +159,7 @@ export default class App extends PureComponent { // repeat events (drag-then-release at same cell, etc.). lastHoverCol = -1 lastHoverRow = -1 + mouseCaptureTarget: DOMElement | undefined // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, // ssh reconnect, laptop wake) and trigger terminal mode re-assert. @@ -578,6 +583,11 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { if (m.action === 'press') { if ((m.button & 0x20) !== 0 && baseButton === 3) { + if (app.mouseCaptureTarget) { + app.props.onMouseUpAt(app.mouseCaptureTarget, col, row, baseButton) + app.mouseCaptureTarget = undefined + } + // Mode-1003 motion with no button held. Dispatch hover; skip the // rest of this handler (no selection, no click-count side effects). // Lost-release recovery: no-button motion while isDragging=true means @@ -611,6 +621,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { } if ((m.button & 0x20) !== 0) { + if (app.mouseCaptureTarget) { + app.props.onMouseDragAt(app.mouseCaptureTarget, col, row, baseButton) + + return + } + // Drag motion: mode-aware extension (char/word/line). onSelectionDrag // calls notifySelectionChange internally — no extra onSelectionChange. app.props.onSelectionDrag(col, row) @@ -628,6 +644,15 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { app.props.onSelectionChange() } + const capture = app.props.onMouseDownAt(col, row, baseButton) + + if (capture) { + app.mouseCaptureTarget = capture + app.clickCount = 0 + + return + } + // Fresh left press. Detect multi-click HERE (not on release) so the // word/line highlight appears immediately and a subsequent drag can // extend by word/line like native macOS. Previously detected on @@ -677,6 +702,13 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // isDragging=true and leave drag-to-scroll's timer running until the // scroll boundary. Only act on non-left releases when we ARE dragging // (so an unrelated middle/right click-release doesn't touch selection). + if (app.mouseCaptureTarget) { + app.props.onMouseUpAt(app.mouseCaptureTarget, col, row, baseButton) + app.mouseCaptureTarget = undefined + + return + } + if (baseButton !== 0) { if (!sel.isDragging) { return diff --git a/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx index 68ba67ea54..408d23c227 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/Box.tsx @@ -8,6 +8,7 @@ import type { DOMElement } from '../dom.js' import type { ClickEvent } from '../events/click-event.js' import type { FocusEvent } from '../events/focus-event.js' import type { KeyboardEvent } from '../events/keyboard-event.js' +import type { MouseEvent } from '../events/mouse-event.js' import type { Styles } from '../styles.js' import * as warn from '../warn.js' export type Props = Except & { @@ -31,6 +32,9 @@ export type Props = Except & { * ancestors; call `event.stopImmediatePropagation()` to stop bubbling. */ onClick?: (event: ClickEvent) => void + onMouseDown?: (event: MouseEvent) => void + onMouseUp?: (event: MouseEvent) => void + onMouseDrag?: (event: MouseEvent) => void onFocus?: (event: FocusEvent) => void onFocusCapture?: (event: FocusEvent) => void onBlur?: (event: FocusEvent) => void @@ -52,7 +56,7 @@ export type Props = Except & { * `` is an essential Ink component to build your layout. It's like `
` in the browser. */ function Box(t0: Props) { - const $ = _c(42) + const $ = _c(48) let autoFocus let children let flexDirection @@ -66,8 +70,11 @@ function Box(t0: Props) { let onFocusCapture let onKeyDown let onKeyDownCapture + let onMouseDown + let onMouseDrag let onMouseEnter let onMouseLeave + let onMouseUp let ref let style let tabIndex @@ -87,11 +94,14 @@ function Box(t0: Props) { onFocusCapture: t11, onBlur: t12, onBlurCapture: t13, - onMouseEnter: t14, - onMouseLeave: t15, - onKeyDown: t16, - onKeyDownCapture: t17, - ...t18 + onMouseDown: t14, + onMouseUp: t15, + onMouseDrag: t16, + onMouseEnter: t17, + onMouseLeave: t18, + onKeyDown: t19, + onKeyDownCapture: t20, + ...t21 } = t0 children = t1 @@ -103,11 +113,14 @@ function Box(t0: Props) { onFocusCapture = t11 onBlur = t12 onBlurCapture = t13 - onMouseEnter = t14 - onMouseLeave = t15 - onKeyDown = t16 - onKeyDownCapture = t17 - style = t18 + onMouseDown = t14 + onMouseUp = t15 + onMouseDrag = t16 + onMouseEnter = t17 + onMouseLeave = t18 + onKeyDown = t19 + onKeyDownCapture = t20 + style = t21 flexWrap = t2 === undefined ? 'nowrap' : t2 flexDirection = t3 === undefined ? 'row' : t3 flexGrow = t4 === undefined ? 0 : t4 @@ -143,11 +156,14 @@ function Box(t0: Props) { $[11] = onFocusCapture $[12] = onKeyDown $[13] = onKeyDownCapture - $[14] = onMouseEnter - $[15] = onMouseLeave - $[16] = ref - $[17] = style - $[18] = tabIndex + $[14] = onMouseDown + $[15] = onMouseUp + $[16] = onMouseDrag + $[17] = onMouseEnter + $[18] = onMouseLeave + $[19] = ref + $[20] = style + $[21] = tabIndex } else { autoFocus = $[1] children = $[2] @@ -162,11 +178,14 @@ function Box(t0: Props) { onFocusCapture = $[11] onKeyDown = $[12] onKeyDownCapture = $[13] - onMouseEnter = $[14] - onMouseLeave = $[15] - ref = $[16] - style = $[17] - tabIndex = $[18] + onMouseDown = $[14] + onMouseUp = $[15] + onMouseDrag = $[16] + onMouseEnter = $[17] + onMouseLeave = $[18] + ref = $[19] + style = $[20] + tabIndex = $[21] } const t1 = style.overflowX ?? style.overflow ?? 'visible' @@ -174,13 +193,13 @@ function Box(t0: Props) { let t3 if ( - $[19] !== flexDirection || - $[20] !== flexGrow || - $[21] !== flexShrink || - $[22] !== flexWrap || - $[23] !== style || - $[24] !== t1 || - $[25] !== t2 + $[22] !== flexDirection || + $[23] !== flexGrow || + $[24] !== flexShrink || + $[25] !== flexWrap || + $[26] !== style || + $[27] !== t1 || + $[28] !== t2 ) { t3 = { flexWrap, @@ -191,35 +210,38 @@ function Box(t0: Props) { overflowX: t1, overflowY: t2 } - $[19] = flexDirection - $[20] = flexGrow - $[21] = flexShrink - $[22] = flexWrap - $[23] = style - $[24] = t1 - $[25] = t2 - $[26] = t3 + $[22] = flexDirection + $[23] = flexGrow + $[24] = flexShrink + $[25] = flexWrap + $[26] = style + $[27] = t1 + $[28] = t2 + $[29] = t3 } else { - t3 = $[26] + t3 = $[29] } let t4 if ( - $[27] !== autoFocus || - $[28] !== children || - $[29] !== onBlur || - $[30] !== onBlurCapture || - $[31] !== onClick || - $[32] !== onFocus || - $[33] !== onFocusCapture || - $[34] !== onKeyDown || - $[35] !== onKeyDownCapture || - $[36] !== onMouseEnter || - $[37] !== onMouseLeave || - $[38] !== ref || - $[39] !== t3 || - $[40] !== tabIndex + $[30] !== autoFocus || + $[31] !== children || + $[32] !== onBlur || + $[33] !== onBlurCapture || + $[34] !== onClick || + $[35] !== onFocus || + $[36] !== onFocusCapture || + $[37] !== onKeyDown || + $[38] !== onKeyDownCapture || + $[39] !== onMouseDown || + $[40] !== onMouseUp || + $[41] !== onMouseDrag || + $[42] !== onMouseEnter || + $[43] !== onMouseLeave || + $[44] !== ref || + $[45] !== t3 || + $[46] !== tabIndex ) { t4 = ( ) - $[27] = autoFocus - $[28] = children - $[29] = onBlur - $[30] = onBlurCapture - $[31] = onClick - $[32] = onFocus - $[33] = onFocusCapture - $[34] = onKeyDown - $[35] = onKeyDownCapture - $[36] = onMouseEnter - $[37] = onMouseLeave - $[38] = ref - $[39] = t3 - $[40] = tabIndex - $[41] = t4 + $[30] = autoFocus + $[31] = children + $[32] = onBlur + $[33] = onBlurCapture + $[34] = onClick + $[35] = onFocus + $[36] = onFocusCapture + $[37] = onKeyDown + $[38] = onKeyDownCapture + $[39] = onMouseDown + $[40] = onMouseUp + $[41] = onMouseDrag + $[42] = onMouseEnter + $[43] = onMouseLeave + $[44] = ref + $[45] = t3 + $[46] = tabIndex + $[47] = t4 } else { - t4 = $[41] + t4 = $[47] } return t4 diff --git a/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts b/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts index 42d59d0353..1750dbeee5 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/event-handlers.ts @@ -1,6 +1,7 @@ import type { ClickEvent } from './click-event.js' import type { FocusEvent } from './focus-event.js' import type { KeyboardEvent } from './keyboard-event.js' +import type { MouseEvent } from './mouse-event.js' import type { PasteEvent } from './paste-event.js' import type { ResizeEvent } from './resize-event.js' @@ -9,6 +10,7 @@ type FocusEventHandler = (event: FocusEvent) => void type PasteEventHandler = (event: PasteEvent) => void type ResizeEventHandler = (event: ResizeEvent) => void type ClickEventHandler = (event: ClickEvent) => void +type MouseEventHandler = (event: MouseEvent) => void type HoverEventHandler = () => void /** @@ -33,6 +35,9 @@ export type EventHandlerProps = { onResize?: ResizeEventHandler onClick?: ClickEventHandler + onMouseDown?: MouseEventHandler + onMouseUp?: MouseEventHandler + onMouseDrag?: MouseEventHandler onMouseEnter?: HoverEventHandler onMouseLeave?: HoverEventHandler } @@ -50,7 +55,10 @@ export const HANDLER_FOR_EVENT: Record< blur: { bubble: 'onBlur', capture: 'onBlurCapture' }, paste: { bubble: 'onPaste', capture: 'onPasteCapture' }, resize: { bubble: 'onResize' }, - click: { bubble: 'onClick' } + click: { bubble: 'onClick' }, + mousedown: { bubble: 'onMouseDown' }, + mouseup: { bubble: 'onMouseUp' }, + mousedrag: { bubble: 'onMouseDrag' } } /** @@ -68,6 +76,9 @@ export const EVENT_HANDLER_PROPS = new Set([ 'onPasteCapture', 'onResize', 'onClick', + 'onMouseDown', + 'onMouseUp', + 'onMouseDrag', 'onMouseEnter', 'onMouseLeave' ]) diff --git a/ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts new file mode 100644 index 0000000000..d42839b5fb --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/events/mouse-event.ts @@ -0,0 +1,18 @@ +import { Event } from './event.js' + +export class MouseEvent extends Event { + readonly col: number + readonly row: number + localCol = 0 + localRow = 0 + readonly cellIsBlank: boolean + readonly button: number + + constructor(col: number, row: number, cellIsBlank: boolean, button: number) { + super() + this.col = col + this.row = row + this.cellIsBlank = cellIsBlank + this.button = button + } +} diff --git a/ui-tui/packages/hermes-ink/src/ink/hit-test.ts b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts index f0d9a31792..c23ce34fe0 100644 --- a/ui-tui/packages/hermes-ink/src/ink/hit-test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/hit-test.ts @@ -1,6 +1,7 @@ import type { DOMElement } from './dom.js' import { ClickEvent } from './events/click-event.js' import type { EventHandlerProps } from './events/event-handlers.js' +import { MouseEvent } from './events/mouse-event.js' import { nodeCache } from './node-cache.js' /** @@ -101,6 +102,51 @@ export function dispatchClick(root: DOMElement, col: number, row: number, cellIs return handled } +type MouseHandler = 'onMouseDown' | 'onMouseUp' | 'onMouseDrag' + +export function dispatchMouse( + root: DOMElement, + col: number, + row: number, + handlerName: MouseHandler, + button: number, + cellIsBlank = false, + target?: DOMElement +): DOMElement | undefined { + let node: DOMElement | undefined = target ?? hitTest(root, col, row) ?? undefined + + if (!node) { + return undefined + } + + const event = new MouseEvent(col, row, cellIsBlank, button) + let handled: DOMElement | undefined + + while (node) { + const handler = node._eventHandlers?.[handlerName] as ((event: MouseEvent) => void) | undefined + + if (handler) { + handled ??= node + const rect = nodeCache.get(node) + + if (rect) { + event.localCol = col - rect.x + event.localRow = row - rect.y + } + + handler(event) + + if (event.didStopImmediatePropagation()) { + return handled + } + } + + node = node.parentNode + } + + return handled +} + /** * Fire onMouseEnter/onMouseLeave as the pointer moves. Like DOM * mouseenter/mouseleave: does NOT bubble — moving between children does diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 96898cee31..ff2507ac65 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -22,7 +22,7 @@ import * as dom from './dom.js' import { KeyboardEvent } from './events/keyboard-event.js' import { FocusManager } from './focus.js' import { emptyFrame, type Frame, type FrameEvent } from './frame.js' -import { dispatchClick, dispatchHover } from './hit-test.js' +import { dispatchClick, dispatchHover, dispatchMouse } from './hit-test.js' import instances from './instances.js' import { LogUpdate } from './log-update.js' import { nodeCache } from './node-cache.js' @@ -1538,6 +1538,42 @@ export default class Ink { return dispatchClick(this.rootNode, col, row, blank) } + dispatchMouseDown(col: number, row: number, button: number): dom.DOMElement | undefined { + if (!this.altScreenActive) { + return undefined + } + + return dispatchMouse( + this.rootNode, + col, + row, + 'onMouseDown', + button, + isEmptyCellAt(this.frontFrame.screen, col, row) + ) + } + dispatchMouseUp(target: dom.DOMElement, col: number, row: number, button: number): void { + if (!this.altScreenActive) { + return + } + + dispatchMouse(this.rootNode, col, row, 'onMouseUp', button, isEmptyCellAt(this.frontFrame.screen, col, row), target) + } + dispatchMouseDrag(target: dom.DOMElement, col: number, row: number, button: number): void { + if (!this.altScreenActive) { + return + } + + dispatchMouse( + this.rootNode, + col, + row, + 'onMouseDrag', + button, + isEmptyCellAt(this.frontFrame.screen, col, row), + target + ) + } dispatchHover(col: number, row: number): void { if (!this.altScreenActive) { return @@ -1764,6 +1800,9 @@ export default class Ink { onCursorDeclaration={this.setCursorDeclaration} onExit={this.unmount} onHoverAt={this.dispatchHover} + onMouseDownAt={this.dispatchMouseDown} + onMouseDragAt={this.dispatchMouseDrag} + onMouseUpAt={this.dispatchMouseUp} onMultiClick={this.handleMultiClick} onOpenHyperlink={this.openHyperlink} onSelectionChange={this.notifySelectionChange} diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 86f5c7a2e2..ca65830053 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -99,14 +99,14 @@ const nextDetailsMode = (m: DetailsMode): DetailsMode => // ── Pure helpers ───────────────────────────────────────────────────── -const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) type PasteSnippet = { label: string; text: string } -const shortCwd = (cwd: string, max = 28) => { - const home = process.env.HOME - const path = home && cwd.startsWith(home) ? `~${cwd.slice(home.length)}` : cwd +const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) - return path.length <= max ? path : `…${path.slice(-(max - 1))}` +const shortCwd = (cwd: string, max = 28) => { + const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd + + return p.length <= max ? p : `…${p.slice(-(max - 1))}` } const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { @@ -332,6 +332,7 @@ function StickyPromptTracker({ if (!s) { return NaN } + const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) return s.isSticky() ? -1 - top : top @@ -356,6 +357,7 @@ function StickyPromptTracker({ if ((offsets[i] ?? 0) + 1 >= top) { continue } + text = userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() break @@ -368,6 +370,85 @@ function StickyPromptTracker({ return null } +function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) { + useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => { + const s = scrollRef.current + + if (!s) { + return NaN + } + + return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}` + }, + () => '' + ) + + const [hover, setHover] = useState(false) + const [grab, setGrab] = useState(null) + + const s = scrollRef.current + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + + if (!vp) { + return + } + + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const scrollable = total > vp + const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp + const travel = Math.max(1, vp - thumb) + const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 + + const jump = (row: number, offset: number) => { + if (!s || !scrollable) { + return + } + s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp))) + } + + return ( + { + const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) + const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) + setGrab(off) + jump(row, off) + }} + onMouseDrag={(e: { localRow?: number }) => + jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2)) + } + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + onMouseUp={() => setGrab(null)} + width={1} + > + {Array.from({ length: vp }, (_, i) => { + const active = i >= thumbTop && i < thumbTop + thumb + + const color = active + ? grab !== null + ? t.color.gold + : hover + ? t.color.amber + : t.color.bronze + : hover + ? t.color.bronze + : t.color.dim + + return ( + + {scrollable ? (active ? '┃' : '│') : ' '} + + ) + })} + + ) +} + // ── App ────────────────────────────────────────────────────────────── export function App({ gw }: { gw: GatewayClient }) { @@ -561,12 +642,16 @@ export function App({ gw }: { gw: GatewayClient }) { const scrollWithSelection = useCallback( (delta: number) => { const s = scrollRef.current - const sel = selection.getState() as - | { anchor?: { row: number }; focus?: { row: number }; isDragging?: boolean } - | null + + const sel = selection.getState() as { + anchor?: { row: number } + focus?: { row: number } + isDragging?: boolean + } | null if (!s || !sel?.anchor || !sel.focus) { s?.scrollBy(delta) + return } @@ -575,11 +660,13 @@ export function App({ gw }: { gw: GatewayClient }) { if (sel.anchor.row < top || sel.anchor.row > bottom) { s.scrollBy(delta) + return } if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { s.scrollBy(delta) + return } @@ -3065,60 +3152,66 @@ export function App({ gw }: { gw: GatewayClient }) { return ( - - - {virtualHistory.topSpacer > 0 ? : null} + + + + {virtualHistory.topSpacer > 0 ? : null} - {visibleHistory.map(row => ( - - {row.msg.kind === 'intro' && row.msg.info ? ( - - - - - ) : row.msg.kind === 'panel' && row.msg.panelData ? ( - - ) : ( - - )} - - ))} + {visibleHistory.map(row => ( + + {row.msg.kind === 'intro' && row.msg.info ? ( + + + + + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( + + ) : ( + + )} + + ))} - {virtualHistory.bottomSpacer > 0 ? : null} + {virtualHistory.bottomSpacer > 0 ? : null} - {showProgressArea && ( - - )} + {showProgressArea && ( + + )} - {showStreamingArea && ( - - )} - - + {showStreamingArea && ( + + )} + + - + + + + + + {clarify && ( diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index e6e23cc45a..cfb91b0597 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -339,6 +339,7 @@ export function TextInput({ if (!focus) { return } + const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) setCur(next) curRef.current = next diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 418ee0c547..005d8cc4c9 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -190,6 +190,7 @@ export const ToolTrail = memo(function ToolTrail({ if (!tools.length || (detailsMode === 'collapsed' && !openTools)) { return } + const id = setInterval(() => setNow(Date.now()), 500) return () => clearInterval(id) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 868a35c4af..4f91a386b4 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -46,6 +46,7 @@ export function useVirtualHistory( if (!s) { return NaN } + const b = Math.floor(s.getScrollTop() / QUANTUM) return s.isSticky() ? -b - 1 : b @@ -122,6 +123,7 @@ export function useVirtualHistory( if (!k) { continue } + const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0) if (h > 0 && heights.current.get(k) !== h) { From 3bc661ea292d4a574f8d76ec01d01cb89131f8cf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 18:26:00 -0500 Subject: [PATCH 106/157] fix: model et al selection on enter --- .../hermes-ink/src/ink/parse-keypress.ts | 3 +- ui-tui/src/app.tsx | 43 ++++++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index 7c795d1f0e..5107f41d97 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -731,7 +731,8 @@ function parseKeypress(s: string = ''): ParsedKey { key.raw = undefined key.name = 'return' } else if (s === '\n') { - key.name = 'enter' + key.raw = undefined + key.name = 'return' } else if (s === '\t') { key.name = 'tab' } else if (s === '\b' || s === '\x1b\b') { diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index ca65830053..703f33ec29 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -406,6 +406,7 @@ function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject { + const row = completions[compIdx] + + if (!row?.text) { + return false + } + + const text = value.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + const next = value.slice(0, compReplace) + text + + if (next === value) { + return false + } + + setInput(next) + + return true + }, + [compIdx, compReplace, completions, input] + ) + const pulseReasoningStreaming = useCallback(() => { if (reasoningStreamingTimerRef.current) { clearTimeout(reasoningStreamingTimerRef.current) @@ -1480,12 +1503,7 @@ export function App({ gw }: { gw: GatewayClient }) { } if (key.tab && completions.length) { - const row = completions[compIdx] - - if (row?.text) { - const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text - setInput(input.slice(0, compReplace) + text) - } + applyCompletion() return } @@ -3122,6 +3140,17 @@ export function App({ gw }: { gw: GatewayClient }) { [dequeue, dispatchSubmission, inputBuf, sid] ) + const submitOrComplete = useCallback( + (value: string) => { + if (value.startsWith('/') && completions.length && applyCompletion(value)) { + return + } + + submit(value) + }, + [applyCompletion, completions.length, submit] + ) + // ── Derived ────────────────────────────────────────────────────── const statusColor = @@ -3389,7 +3418,7 @@ export function App({ gw }: { gw: GatewayClient }) { columns={Math.max(20, cols - 3)} onChange={setInput} onPaste={handleTextPaste} - onSubmit={submit} + onSubmit={submitOrComplete} placeholder={empty ? PLACEHOLDER : busy ? 'Ctrl+C to interrupt…' : ''} value={input} /> From 4cbf54fb332a85550f43118acc89277516c66ac7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 19:38:04 -0500 Subject: [PATCH 107/157] chore: uptick --- ui-tui/packages/hermes-ink/src/ink/frame.ts | 2 + ui-tui/packages/hermes-ink/src/ink/ink.tsx | 19 +- ui-tui/packages/hermes-ink/src/ink/output.ts | 53 ++- .../hermes-ink/src/ink/parse-keypress.ts | 5 +- .../src/ink/render-node-to-output.ts | 7 + .../packages/hermes-ink/src/ink/renderer.ts | 2 + ui-tui/src/app.tsx | 387 +++++++++--------- ui-tui/src/hooks/useCompletion.ts | 46 ++- 8 files changed, 282 insertions(+), 239 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/frame.ts b/ui-tui/packages/hermes-ink/src/ink/frame.ts index 873b703d92..b85c0ad944 100644 --- a/ui-tui/packages/hermes-ink/src/ink/frame.ts +++ b/ui-tui/packages/hermes-ink/src/ink/frame.ts @@ -11,6 +11,8 @@ export type Frame = { readonly scrollHint?: ScrollHint | null /** A ScrollBox has remaining pendingScrollDelta — schedule another frame. */ readonly scrollDrainPending?: boolean + /** Absolute overlay moved/resized — schedule corrective frame without prevScreen. */ + readonly absoluteOverlayMoved?: boolean } export function emptyFrame( diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index ff2507ac65..7daa876ac3 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -903,21 +903,12 @@ export default class Ink { // becomes frontFrame (= next frame's prevScreen). If we applied the // selection overlay, that buffer has inverted cells. selActive/hlActive // are only ever true in alt-screen; in main-screen this is false→false. - this.prevFrameContaminated = selActive || hlActive + this.prevFrameContaminated = selActive || hlActive || !!frame.absoluteOverlayMoved - // A ScrollBox has pendingScrollDelta left to drain — schedule the next - // frame. MUST NOT call this.scheduleRender() here: we're inside a - // trailing-edge throttle invocation, timerId is undefined, and lodash's - // debounce sees timeSinceLastCall >= wait (last call was at the start - // of this window) → leadingEdge fires IMMEDIATELY → double render ~0.1ms - // apart → jank. Use a plain timeout. If a wheel event arrives first, - // its scheduleRender path fires a render which clears this timer at - // the top of onRender — no double. - // - // Drain frames are cheap (DECSTBM + ~10 patches, ~200 bytes) so run at - // quarter interval (~250fps, setTimeout practical floor) for max scroll - // speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle. - if (frame.scrollDrainPending) { + // Schedule corrective frame for scroll drain or absolute overlay resize. + // Plain timeout instead of scheduleRender to avoid double-render from + // lodash throttle's leadingEdge firing inside a trailing invocation. + if (frame.scrollDrainPending || frame.absoluteOverlayMoved) { this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2) } diff --git a/ui-tui/packages/hermes-ink/src/ink/output.ts b/ui-tui/packages/hermes-ink/src/ink/output.ts index ab417fcaed..f52bf06363 100644 --- a/ui-tui/packages/hermes-ink/src/ink/output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/output.ts @@ -371,10 +371,10 @@ export default class Output { continue } - // Skip rows covered by an absolute-positioned node's clear. + // Exclude cells covered by an absolute-positioned node's clear. // Absolute nodes overlay normal-flow siblings, so prevScreen in - // that region holds the absolute node's stale paint — blitting - // it back would ghost. See absoluteClears collection above. + // that region holds stale overlay paint. If we blit those cells + // back, removed/moved overlays ghost as a duplicate. if (absoluteClears.length === 0) { blitRegion(screen, src, startX, startY, maxX, maxY) blitCells += (maxY - startY) * (maxX - startX) @@ -382,20 +382,45 @@ export default class Output { continue } - let rowStart = startY + for (let row = startY; row < maxY; row++) { + let spans: [number, number][] = [[startX, maxX]] - for (let row = startY; row <= maxY; row++) { - const excluded = - row < maxY && - absoluteClears.some(r => row >= r.y && row < r.y + r.height && startX >= r.x && maxX <= r.x + r.width) - - if (excluded || row === maxY) { - if (row > rowStart) { - blitRegion(screen, src, startX, rowStart, maxX, row) - blitCells += (row - rowStart) * (maxX - startX) + for (const r of absoluteClears) { + if (row < r.y || row >= r.y + r.height || !spans.length) { + break } - rowStart = row + 1 + const cs = Math.max(startX, r.x) + const ce = Math.min(maxX, r.x + r.width) + + if (cs >= ce) { + continue + } + + const next: [number, number][] = [] + + for (const [sx, ex] of spans) { + if (ce <= sx || cs >= ex) { + next.push([sx, ex]) + + continue + } + + if (sx < cs) { + next.push([sx, cs]) + } + + if (ce < ex) { + next.push([ce, ex]) + } + } + + spans = next + } + + for (const [sx, ex] of spans) { + blitRegion(screen, src, sx, row, ex, row + 1) + blitCells += ex - sx } } diff --git a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts index 5107f41d97..ca77058d66 100644 --- a/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts +++ b/ui-tui/packages/hermes-ink/src/ink/parse-keypress.ts @@ -727,10 +727,7 @@ function parseKeypress(s: string = ''): ParsedKey { return createNavKey(s, 'mouse', false) } - if (s === '\r') { - key.raw = undefined - key.name = 'return' - } else if (s === '\n') { + if (s === '\r' || s === '\n') { key.raw = undefined key.name = 'return' } else if (s === '\t') { diff --git a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts index d9057725fe..5c9e62b468 100644 --- a/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts +++ b/ui-tui/packages/hermes-ink/src/ink/render-node-to-output.ts @@ -30,15 +30,21 @@ function isXtermJsHost(): boolean { // shift layout → narrow damage bounds → O(changed cells) diff instead of // O(rows×cols). let layoutShifted = false +let absoluteOverlayMoved = false export function resetLayoutShifted(): void { layoutShifted = false + absoluteOverlayMoved = false } export function didLayoutShift(): boolean { return layoutShifted } +export function didAbsoluteOverlayMove(): boolean { + return absoluteOverlayMoved +} + // DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes // between frames (and nothing else moved), log-update.ts can emit a // hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole @@ -496,6 +502,7 @@ function renderNodeToOutput( if (positionChanged) { layoutShifted = true + absoluteOverlayMoved ||= node.style.position === 'absolute' } if (cached && (node.dirty || positionChanged)) { diff --git a/ui-tui/packages/hermes-ink/src/ink/renderer.ts b/ui-tui/packages/hermes-ink/src/ink/renderer.ts index ca89182d7e..38e5276354 100644 --- a/ui-tui/packages/hermes-ink/src/ink/renderer.ts +++ b/ui-tui/packages/hermes-ink/src/ink/renderer.ts @@ -5,6 +5,7 @@ import type { Frame } from './frame.js' import { consumeAbsoluteRemovedFlag } from './node-cache.js' import Output from './output.js' import renderNodeToOutput, { + didAbsoluteOverlayMove, getScrollDrainNode, getScrollHint, resetLayoutShifted, @@ -135,6 +136,7 @@ export default function createRenderer(node: DOMElement, stylePool: StylePool): } return { + absoluteOverlayMoved: didAbsoluteOverlayMove(), scrollHint: options.altScreen ? getScrollHint() : null, scrollDrainPending: drainNode !== null, screen: renderedScreen, diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 703f33ec29..e9687ce7c3 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -288,9 +288,17 @@ function StatusRule({ // ── PromptBox ──────────────────────────────────────────────────────── -function PromptBox({ children, color }: { children: React.ReactNode; color: string }) { +function FloatBox({ children, color }: { children: React.ReactNode; color: string }) { return ( - + {children} ) @@ -559,28 +567,6 @@ export function App({ gw }: { gw: GatewayClient }) { const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) - const applyCompletion = useCallback( - (value = input) => { - const row = completions[compIdx] - - if (!row?.text) { - return false - } - - const text = value.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text - const next = value.slice(0, compReplace) + text - - if (next === value) { - return false - } - - setInput(next) - - return true - }, - [compIdx, compReplace, completions, input] - ) - const pulseReasoningStreaming = useCallback(() => { if (reasoningStreamingTimerRef.current) { clearTimeout(reasoningStreamingTimerRef.current) @@ -1503,7 +1489,12 @@ export function App({ gw }: { gw: GatewayClient }) { } if (key.tab && completions.length) { - applyCompletion() + const row = completions[compIdx] + + if (row?.text) { + const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + setInput(input.slice(0, compReplace) + text) + } return } @@ -3080,6 +3071,23 @@ export function App({ gw }: { gw: GatewayClient }) { const submit = useCallback( (value: string) => { + if (value.startsWith('/') && completions.length) { + const row = completions[compIdx] + + if (row?.text) { + const text = + value.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + + const next = value.slice(0, compReplace) + text + + if (next !== value) { + setInput(next) + + return + } + } + } + if (!value.trim() && !inputBuf.length) { const now = Date.now() const dbl = now - lastEmptyAt.current < 450 @@ -3137,18 +3145,7 @@ export function App({ gw }: { gw: GatewayClient }) { dispatchSubmission([...inputBuf, value].join('\n')) }, - [dequeue, dispatchSubmission, inputBuf, sid] - ) - - const submitOrComplete = useCallback( - (value: string) => { - if (value.startsWith('/') && completions.length && applyCompletion(value)) { - return - } - - submit(value) - }, - [applyCompletion, completions.length, submit] + [compIdx, compReplace, completions, dequeue, dispatchSubmission, inputBuf, sid] ) // ── Derived ────────────────────────────────────────────────────── @@ -3243,102 +3240,6 @@ export function App({ gw }: { gw: GatewayClient }) { - {clarify && ( - - answerClarify('')} - req={clarify} - t={theme} - /> - - )} - - {approval && ( - - { - rpc('approval.respond', { choice, session_id: sid }).then(r => { - if (!r) { - return - } - - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }) - }} - req={approval} - t={theme} - /> - - )} - - {sudo && ( - - { - rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - - setSudo(null) - setStatus('running…') - }) - }} - t={theme} - /> - - )} - - {secret && ( - - { - rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { - if (!r) { - return - } - - setSecret(null) - setStatus('running…') - }) - }} - sub={`for ${secret.envVar}`} - t={theme} - /> - - )} - - {picker && ( - - setPicker(false)} onSelect={resumeById} t={theme} /> - - )} - - {modelPicker && ( - - setModelPicker(false)} - onSelect={value => { - setModelPicker(false) - slash(`/model ${value}`) - }} - sessionId={sid} - t={theme} - /> - - )} - {bgTasks.size > 0 && ( @@ -3356,44 +3257,177 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {statusBar && ( - - )} + + {statusBar && ( + + )} - {pager && ( - - {pager.title && ( - - - {pager.title} - - - )} + {(clarify || approval || sudo || secret || picker || modelPicker || pager || completions.length > 0) && ( + + {clarify && ( + + answerClarify('')} + req={clarify} + t={theme} + /> + + )} - {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( - {line} - ))} + {approval && ( + + { + rpc('approval.respond', { choice, session_id: sid }).then(r => { + if (!r) { + return + } - - - {pager.offset + pagerPageSize < pager.lines.length - ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` - : `end · q to close (${pager.lines.length} lines)`} - + setApproval(null) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + setStatus('running…') + }) + }} + req={approval} + t={theme} + /> + + )} + + {sudo && ( + + { + rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + setSudo(null) + setStatus('running…') + }) + }} + t={theme} + /> + + )} + + {secret && ( + + { + rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { + if (!r) { + return + } + + setSecret(null) + setStatus('running…') + }) + }} + sub={`for ${secret.envVar}`} + t={theme} + /> + + )} + + {picker && ( + + setPicker(false)} onSelect={resumeById} t={theme} /> + + )} + + {modelPicker && ( + + setModelPicker(false)} + onSelect={value => { + setModelPicker(false) + slash(`/model ${value}`) + }} + sessionId={sid} + t={theme} + /> + + )} + + {pager && ( + + + {pager.title && ( + + + {pager.title} + + + )} + + {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + + {pager.offset + pagerPageSize < pager.lines.length + ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` + : `end · q to close (${pager.lines.length} lines)`} + + + + + )} + + {!!completions.length && ( + + + {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { + const active = Math.max(0, compIdx - 8) + i === compIdx + + const bg = active ? theme.color.dim : undefined + const fg = theme.color.cornsilk + + return ( + + + {' '} + {item.display} + + + {item.meta ? ( + + {' '} + {item.meta} + + ) : null} + + ) + })} + + + )} - - )} + )} + {!isBlocked && ( @@ -3418,7 +3452,7 @@ export function App({ gw }: { gw: GatewayClient }) { columns={Math.max(20, cols - 3)} onChange={setInput} onPaste={handleTextPaste} - onSubmit={submitOrComplete} + onSubmit={submit} placeholder={empty ? PLACEHOLDER : busy ? 'Ctrl+C to interrupt…' : ''} value={input} /> @@ -3426,23 +3460,6 @@ export function App({ gw }: { gw: GatewayClient }) { )} - {!!completions.length && ( - - {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { - const active = Math.max(0, compIdx - 8) + i === compIdx - - return ( - - - {item.display} - - {item.meta ? {item.meta} : null} - - ) - })} - - )} - {!empty && !sid && ⚕ {status}} diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 1c74872c1d..aae1993240 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -1,4 +1,4 @@ -import { startTransition, useEffect, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' @@ -11,16 +11,20 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const ref = useRef('') useEffect(() => { - if (blocked) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) + const clear = () => { + if (!completions.length) { + return } - return + setCompletions([]) + setCompIdx(0) } - if (input === ref.current) { + if (blocked || input === ref.current) { + if (blocked) { + clear() + } + return } @@ -30,10 +34,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null if (!isSlash && !pathWord) { - if (completions.length) { - setCompletions([]) - setCompIdx(0) - } + clear() return } @@ -53,23 +54,24 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient return } - startTransition(() => { - setCompletions(r?.items ?? []) - setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) - }) + setCompletions(r?.items ?? []) + setCompIdx(0) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) }) .catch((e: unknown) => { if (ref.current !== input) { return } - const meta = e instanceof Error && e.message ? e.message : 'unavailable' - startTransition(() => { - setCompletions([{ text: '', display: 'completion unavailable', meta }]) - setCompIdx(0) - setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) - }) + setCompletions([ + { + text: '', + display: 'completion unavailable', + meta: e instanceof Error && e.message ? e.message : 'unavailable' + } + ]) + setCompIdx(0) + setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) }) }, 60) From 99d859ce4ab116f7f6dc5dca57a08dda4aa7bd1a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 14 Apr 2026 22:30:18 -0500 Subject: [PATCH 108/157] feat: refactor by splitting up app and doing proper state --- cli.py | 2 +- hermes_cli/skin_engine.py | 4 +- ui-tui/package-lock.json | 36 + ui-tui/package.json | 1 + ui-tui/src/app.tsx | 3482 ++++--------------- ui-tui/src/app/constants.ts | 15 + ui-tui/src/app/createGatewayEventHandler.ts | 487 +++ ui-tui/src/app/createSlashHandler.ts | 1058 ++++++ ui-tui/src/app/gatewayContext.tsx | 24 + ui-tui/src/app/helpers.ts | 167 + ui-tui/src/app/interfaces.ts | 67 + ui-tui/src/app/overlayStore.ts | 41 + ui-tui/src/app/uiStore.ts | 41 + ui-tui/src/app/useComposerState.ts | 199 ++ ui-tui/src/app/useInputHandlers.ts | 345 ++ ui-tui/src/app/useTurnState.ts | 296 ++ ui-tui/src/components/activityLane.tsx | 23 - ui-tui/src/components/appChrome.tsx | 227 ++ ui-tui/src/components/appLayout.tsx | 248 ++ ui-tui/src/components/appOverlays.tsx | 175 + ui-tui/src/components/messageLine.tsx | 2 +- ui-tui/src/components/queuedMessages.tsx | 10 - ui-tui/src/components/thinking.tsx | 18 +- ui-tui/src/entry.tsx | 1 - ui-tui/src/lib/history.ts | 4 - ui-tui/src/lib/text.ts | 12 +- ui-tui/src/theme.ts | 41 +- 27 files changed, 4087 insertions(+), 2939 deletions(-) create mode 100644 ui-tui/src/app/constants.ts create mode 100644 ui-tui/src/app/createGatewayEventHandler.ts create mode 100644 ui-tui/src/app/createSlashHandler.ts create mode 100644 ui-tui/src/app/gatewayContext.tsx create mode 100644 ui-tui/src/app/helpers.ts create mode 100644 ui-tui/src/app/interfaces.ts create mode 100644 ui-tui/src/app/overlayStore.ts create mode 100644 ui-tui/src/app/uiStore.ts create mode 100644 ui-tui/src/app/useComposerState.ts create mode 100644 ui-tui/src/app/useInputHandlers.ts create mode 100644 ui-tui/src/app/useTurnState.ts delete mode 100644 ui-tui/src/components/activityLane.tsx create mode 100644 ui-tui/src/components/appChrome.tsx create mode 100644 ui-tui/src/components/appLayout.tsx create mode 100644 ui-tui/src/components/appOverlays.tsx diff --git a/cli.py b/cli.py index d696cdef84..80b5b088db 100644 --- a/cli.py +++ b/cli.py @@ -3752,7 +3752,7 @@ class HermesCLI: skin = get_active_skin() separator_color = skin.get_color("banner_dim", "#B8860B") accent_color = skin.get_color("ui_accent", "#FFBF00") - label_color = skin.get_color("ui_label", "#4dd0e1") + label_color = skin.get_color("ui_label", "#DAA520") except Exception: separator_color, accent_color, label_color = "#B8860B", "#FFBF00", "cyan" toolsets_info = "" diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index b992ada06f..5b406f1f56 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -23,7 +23,7 @@ All fields are optional. Missing values inherit from the ``default`` skin. banner_dim: "#B8860B" # Dim/muted text (separators, labels) banner_text: "#FFF8DC" # Body text (tool names, skill names) ui_accent: "#FFBF00" # General UI accent - ui_label: "#4dd0e1" # UI labels + ui_label: "#DAA520" # UI labels (warm gold; teal clashed w/ default banner gold) ui_ok: "#4caf50" # Success indicators ui_error: "#ef5350" # Error indicators ui_warn: "#ffa726" # Warning indicators @@ -163,7 +163,7 @@ _BUILTIN_SKINS: Dict[str, Dict[str, Any]] = { "banner_dim": "#B8860B", "banner_text": "#FFF8DC", "ui_accent": "#FFBF00", - "ui_label": "#4dd0e1", + "ui_label": "#DAA520", "ui_ok": "#4caf50", "ui_error": "#ef5350", "ui_warn": "#ffa726", diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 04c2767975..0b33e6e334 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "dependencies": { "@hermes/ink": "file:./packages/hermes-ink", + "@nanostores/react": "^1.1.0", "ink": "^6.8.0", "ink-text-input": "^6.0.0", "react": "^19.2.4", @@ -1115,6 +1116,25 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nanostores/react": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.1.0.tgz", + "integrity": "sha512-MbH35fjhcf7LAubYX5vhOChYUfTLzNLqH/mBGLVsHkcvjy0F8crO1WQwdmQ2xKbAmtpalDa2zBt3Hlg5kqr8iw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "nanostores": "^1.2.0", + "react": ">=18.0.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", @@ -4761,6 +4781,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/nanostores": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz", + "integrity": "sha512-F0wCzbsH80G7XXo0Jd9/AVQC7ouWY6idUCTnMwW5t/Rv9W8qmO6endavDwg7TNp5GbugwSukFMVZqzPSrSMndg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", diff --git a/ui-tui/package.json b/ui-tui/package.json index e6e10ec06c..4776f0830d 100644 --- a/ui-tui/package.json +++ b/ui-tui/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@hermes/ink": "file:./packages/hermes-ink", + "@nanostores/react": "^1.1.0", "ink": "^6.8.0", "ink-text-input": "^6.0.0", "react": "^19.2.4", diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index e9687ce7c3..79edcce289 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,462 +1,33 @@ -import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { MAX_HISTORY, MOUSE_TRACKING, PASTE_SNIPPET_RE, STARTUP_RESUME_ID, WHEEL_SCROLL_STEP } from './app/constants.js' +import { createGatewayEventHandler } from './app/createGatewayEventHandler.js' +import { createSlashHandler } from './app/createSlashHandler.js' +import { GatewayProvider } from './app/gatewayContext.js' import { - AlternateScreen, - Box, - NoSelect, - ScrollBox, - type ScrollBoxHandle, - Text, - useApp, - useHasSelection, - useInput, - useSelection, - useStdout -} from '@hermes/ink' -import { type RefObject, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' - -import { Banner, Panel, SessionPanel } from './components/branding.js' -import { MaskedPrompt } from './components/maskedPrompt.js' -import { MessageLine } from './components/messageLine.js' -import { ModelPicker } from './components/modelPicker.js' -import { ApprovalPrompt, ClarifyPrompt } from './components/prompts.js' -import { QueuedMessages } from './components/queuedMessages.js' -import { SessionPicker } from './components/sessionPicker.js' -import { type PasteEvent, TextInput } from './components/textInput.js' -import { ToolTrail } from './components/thinking.js' -import { HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ZERO } from './constants.js' + fmtDuration, + imageTokenMeta, + introMsg, + looksLikeSlashCommand, + resolveDetailsMode, + shortCwd, + toTranscriptMessages +} from './app/helpers.js' +import { type TranscriptRow } from './app/interfaces.js' +import { $isBlocked, $overlayState, patchOverlayState } from './app/overlayStore.js' +import { $uiState, getUiState, patchUiState } from './app/uiStore.js' +import { useComposerState } from './app/useComposerState.js' +import { useInputHandlers } from './app/useInputHandlers.js' +import { useTurnState } from './app/useTurnState.js' +import { AppLayout } from './components/appLayout.js' +import { INTERPOLATION_RE, ZERO } from './constants.js' import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' -import { useCompletion } from './hooks/useCompletion.js' -import { useInputHistory } from './hooks/useInputHistory.js' -import { useQueue } from './hooks/useQueue.js' import { useVirtualHistory } from './hooks/useVirtualHistory.js' -import { writeOsc52Clipboard } from './lib/osc52.js' import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' -import { - buildToolTrailLine, - fmtK, - hasInterpolation, - isToolTrailResultLine, - isTransientTrailLine, - pasteTokenLabel, - pick, - sameToolTrailGroup, - stripTrailingPasteNewlines, - toolTrailLabel, - userDisplay -} from './lib/text.js' -import { DEFAULT_THEME, fromSkin, type Theme } from './theme.js' -import type { - ActiveTool, - ActivityItem, - ApprovalReq, - ClarifyReq, - DetailsMode, - Msg, - PanelSection, - SecretReq, - SessionInfo, - SlashCatalog, - SudoReq, - Usage -} from './types.js' - -// ── Constants ──────────────────────────────────────────────────────── - -const PLACEHOLDER = pick(PLACEHOLDERS) -const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() - -const LARGE_PASTE = { chars: 8000, lines: 80 } -const MAX_HISTORY = 800 -const REASONING_PULSE_MS = 700 -const STREAM_BATCH_MS = 16 -const WHEEL_SCROLL_STEP = 3 -const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase()) -const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g - -const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] - -const parseDetailsMode = (v: unknown): DetailsMode | null => { - const s = typeof v === 'string' ? v.trim().toLowerCase() : '' - - return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null -} - -const resolveDetailsMode = (d: any): DetailsMode => - parseDetailsMode(d?.details_mode) ?? - { full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[ - String(d?.thinking_mode ?? '') - .trim() - .toLowerCase() - ] ?? - 'collapsed' - -const nextDetailsMode = (m: DetailsMode): DetailsMode => - DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! - -// ── Pure helpers ───────────────────────────────────────────────────── - -type PasteSnippet = { label: string; text: string } - -const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) - -const shortCwd = (cwd: string, max = 28) => { - const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd - - return p.length <= max ? p : `…${p.slice(-(max - 1))}` -} - -const imageTokenMeta = (info: { height?: number; token_estimate?: number; width?: number } | null | undefined) => { - const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' - - const tok = - typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' - - return [dims, tok].filter(Boolean).join(' · ') -} - -const looksLikeSlashCommand = (text: string) => { - if (!text.startsWith('/')) { - return false - } - - const first = text.split(/\s+/, 1)[0] || '' - - return !first.slice(1).includes('/') -} - -const toTranscriptMessages = (rows: unknown): Msg[] => { - if (!Array.isArray(rows)) { - return [] - } - - const result: Msg[] = [] - let pendingTools: string[] = [] - - for (const row of rows) { - if (!row || typeof row !== 'object') { - continue - } - - const role = (row as any).role - const text = (row as any).text - - if (role === 'tool') { - const name = (row as any).name ?? 'tool' - const ctx = (row as any).context ?? '' - pendingTools.push(buildToolTrailLine(name, ctx)) - - continue - } - - if (typeof text !== 'string' || !text.trim()) { - continue - } - - if (role === 'assistant') { - const msg: Msg = { role, text } - - if (pendingTools.length) { - msg.tools = pendingTools - pendingTools = [] - } - - result.push(msg) - - continue - } - - if (role === 'user' || role === 'system') { - pendingTools = [] - result.push({ role, text }) - } - } - - return result -} - -// ── StatusRule ──────────────────────────────────────────────────────── - -function ctxBarColor(pct: number | undefined, t: Theme) { - if (pct == null) { - return t.color.dim - } - - if (pct >= 95) { - return t.color.statusCritical - } - - if (pct > 80) { - return t.color.statusBad - } - - if (pct >= 50) { - return t.color.statusWarn - } - - return t.color.statusGood -} - -function ctxBar(pct: number | undefined, w = 10) { - const p = Math.max(0, Math.min(100, pct ?? 0)) - const filled = Math.round((p / 100) * w) - - return '█'.repeat(filled) + '░'.repeat(w - filled) -} - -function fmtDuration(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)) - const hours = Math.floor(total / 3600) - const mins = Math.floor((total % 3600) / 60) - const secs = total % 60 - - if (hours > 0) { - return `${hours}h ${mins}m` - } - - if (mins > 0) { - return `${mins}m ${secs}s` - } - - return `${secs}s` -} - -function StatusRule({ - cwdLabel, - cols, - status, - statusColor, - model, - usage, - bgCount, - durationLabel, - voiceLabel, - t -}: { - cwdLabel: string - cols: number - status: string - statusColor: string - model: string - usage: Usage - bgCount: number - durationLabel?: string - voiceLabel?: string - t: Theme -}) { - const pct = usage.context_percent - const barColor = ctxBarColor(pct, t) - - const ctxLabel = usage.context_max - ? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` - : usage.total > 0 - ? `${fmtK(usage.total)} tok` - : '' - - const pctLabel = pct != null ? `${pct}%` : '' - const bar = usage.context_max ? ctxBar(pct) : '' - - const leftWidth = Math.max(12, cols - cwdLabel.length - 3) - - return ( - - - - {'─ '} - {status} - │ {model} - {ctxLabel ? │ {ctxLabel} : null} - {bar ? ( - - {' │ '} - [{bar}] {pctLabel} - - ) : null} - {durationLabel ? │ {durationLabel} : null} - {voiceLabel ? │ {voiceLabel} : null} - {bgCount > 0 ? │ {bgCount} bg : null} - - - - {cwdLabel} - - ) -} - -// ── PromptBox ──────────────────────────────────────────────────────── - -function FloatBox({ children, color }: { children: React.ReactNode; color: string }) { - return ( - - {children} - - ) -} - -const upperBound = (arr: ArrayLike, target: number) => { - let lo = 0 - let hi = arr.length - - while (lo < hi) { - const mid = (lo + hi) >> 1 - - if (arr[mid]! <= target) { - lo = mid + 1 - } else { - hi = mid - } - } - - return lo -} - -function StickyPromptTracker({ - messages, - offsets, - scrollRef, - onChange -}: { - messages: readonly Msg[] - offsets: ArrayLike - scrollRef: RefObject - onChange: (text: string) => void -}) { - useSyncExternalStore( - useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), - () => { - const s = scrollRef.current - - if (!s) { - return NaN - } - - const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) - - return s.isSticky() ? -1 - top : top - }, - () => NaN - ) - - const s = scrollRef.current - const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) - - let text = '' - - if (!(s?.isSticky() ?? true) && messages.length) { - const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) - - if (!(messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top)) { - for (let i = first - 1; i >= 0; i--) { - if (messages[i]?.role !== 'user') { - continue - } - - if ((offsets[i] ?? 0) + 1 >= top) { - continue - } - - text = userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() - - break - } - } - } - - useEffect(() => onChange(text), [onChange, text]) - - return null -} - -function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) { - useSyncExternalStore( - useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), - () => { - const s = scrollRef.current - - if (!s) { - return NaN - } - - return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}` - }, - () => '' - ) - - const [hover, setHover] = useState(false) - const [grab, setGrab] = useState(null) - - const s = scrollRef.current - const vp = Math.max(0, s?.getViewportHeight() ?? 0) - - if (!vp) { - return - } - - const total = Math.max(vp, s?.getScrollHeight() ?? vp) - const scrollable = total > vp - const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp - const travel = Math.max(1, vp - thumb) - const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) - const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 - - const jump = (row: number, offset: number) => { - if (!s || !scrollable) { - return - } - - s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp))) - } - - return ( - { - const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) - const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) - setGrab(off) - jump(row, off) - }} - onMouseDrag={(e: { localRow?: number }) => - jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2)) - } - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - onMouseUp={() => setGrab(null)} - width={1} - > - {Array.from({ length: vp }, (_, i) => { - const active = i >= thumbTop && i < thumbTop + thumb - - const color = active - ? grab !== null - ? t.color.gold - : hover - ? t.color.amber - : t.color.bronze - : hover - ? t.color.bronze - : t.color.dim - - return ( - - {scrollable ? (active ? '┃' : '│') : ' '} - - ) - })} - - ) -} +import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js' +import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js' // ── App ────────────────────────────────────────────────────────────── @@ -489,154 +60,55 @@ export function App({ gw }: { gw: GatewayClient }) { // ── State ──────────────────────────────────────────────────────── - const [input, setInput] = useState('') - const [inputBuf, setInputBuf] = useState([]) const [messages, setMessages] = useState([]) const [historyItems, setHistoryItems] = useState([]) - const [status, setStatus] = useState('summoning hermes…') - const [sid, setSid] = useState(null) - const [theme, setTheme] = useState(DEFAULT_THEME) - const [info, setInfo] = useState(null) - const [activity, setActivity] = useState([]) - 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 [sudo, setSudo] = useState(null) - const [secret, setSecret] = useState(null) - const [modelPicker, setModelPicker] = useState(false) - const [picker, setPicker] = useState(false) - const [reasoning, setReasoning] = useState('') - const [reasoningActive, setReasoningActive] = useState(false) - const [reasoningStreaming, setReasoningStreaming] = useState(false) - const [statusBar, setStatusBar] = useState(true) const [lastUserMsg, setLastUserMsg] = useState('') const [stickyPrompt, setStickyPrompt] = useState('') - const [pasteSnips, setPasteSnips] = useState([]) - const [streaming, setStreaming] = useState('') - const [turnTrail, setTurnTrail] = useState([]) - const [bgTasks, setBgTasks] = useState>(new Set()) const [catalog, setCatalog] = useState(null) - const [pager, setPager] = useState<{ lines: string[]; offset: number; title?: string } | null>(null) const [voiceEnabled, setVoiceEnabled] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [bellOnComplete, setBellOnComplete] = useState(false) const [clockNow, setClockNow] = useState(() => Date.now()) - const [detailsMode, setDetailsMode] = useState('collapsed') + const ui = useStore($uiState) + const overlay = useStore($overlayState) + const isBlocked = useStore($isBlocked) // ── Refs ───────────────────────────────────────────────────────── - const activityIdRef = useRef(0) - const toolCompleteRibbonRef = useRef<{ label: string; line: string } | null>(null) - const buf = useRef('') - const interruptedRef = useRef(false) - const reasoningRef = useRef('') const slashRef = useRef<(cmd: string) => boolean>(() => false) const lastEmptyAt = useRef(0) - const lastStatusNoteRef = useRef('') - const protocolWarnedRef = useRef(false) const colsRef = useRef(cols) - const turnToolsRef = useRef([]) - const persistedToolLabelsRef = useRef>(new Set()) - const streamTimerRef = useRef | null>(null) - const reasoningTimerRef = useRef | null>(null) - const reasoningStreamingTimerRef = useRef | null>(null) - const statusTimerRef = useRef | null>(null) - const busyRef = useRef(busy) - const sidRef = useRef(sid) const scrollRef = useRef(null) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) + const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) + const submitRef = useRef<(value: string) => void>(() => {}) const configMtimeRef = useRef(0) colsRef.current = cols - busyRef.current = busy - sidRef.current = sid - reasoningRef.current = reasoning // ── Hooks ──────────────────────────────────────────────────────── const hasSelection = useHasSelection() const selection = useSelection() + const turn = useTurnState() + const turnActions = turn.actions + const turnRefs = turn.refs + const turnState = turn.state - const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = - useQueue() + const composer = useComposerState({ + gw, + onClipboardPaste: quiet => clipboardPasteRef.current(quiet), + submitRef + }) - const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() - const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, blocked(), gw) - - const pulseReasoningStreaming = useCallback(() => { - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - } - - setReasoningActive(true) - setReasoningStreaming(true) - reasoningStreamingTimerRef.current = setTimeout(() => { - reasoningStreamingTimerRef.current = null - setReasoningStreaming(false) - }, REASONING_PULSE_MS) - }, []) - - const scheduleStreaming = useCallback(() => { - if (streamTimerRef.current) { - return - } - - streamTimerRef.current = setTimeout(() => { - streamTimerRef.current = null - setStreaming(buf.current.trimStart()) - }, STREAM_BATCH_MS) - }, []) - - const scheduleReasoning = useCallback(() => { - if (reasoningTimerRef.current) { - return - } - - reasoningTimerRef.current = setTimeout(() => { - reasoningTimerRef.current = null - setReasoning(reasoningRef.current) - }, STREAM_BATCH_MS) - }, []) - - const endReasoningPhase = useCallback(() => { - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - reasoningStreamingTimerRef.current = null - } - - setReasoningStreaming(false) - setReasoningActive(false) - }, []) - - useEffect( - () => () => { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current) - } - - if (reasoningTimerRef.current) { - clearTimeout(reasoningTimerRef.current) - } - - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - } - }, - [] - ) - - function blocked() { - return !!(clarify || approval || modelPicker || picker || secret || sudo || pager) - } + const composerActions = composer.actions + const composerRefs = composer.refs + const composerState = composer.state const empty = !messages.length - const isBlocked = blocked() - const virtualRows = useMemo( + const virtualRows = useMemo( () => historyItems.map((msg, index) => ({ index, @@ -704,17 +176,17 @@ export function App({ gw }: { gw: GatewayClient }) { // ── Resize RPC ─────────────────────────────────────────────────── useEffect(() => { - if (!sid || !stdout) { + if (!ui.sid || !stdout) { return } - const onResize = () => rpc('terminal.resize', { session_id: sid, cols: stdout.columns ?? 80 }) + const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) stdout.on('resize', onResize) return () => { stdout.off('resize', onResize) } - }, [sid, stdout]) // eslint-disable-line react-hooks/exhaustive-deps + }, [stdout, ui.sid]) // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { const id = setInterval(() => setClockNow(Date.now()), 1000) @@ -740,7 +212,7 @@ export function App({ gw }: { gw: GatewayClient }) { const page = useCallback((text: string, title?: string) => { const lines = text.split('\n') - setPager({ lines, offset: 0, title }) + patchOverlayState({ pager: { lines, offset: 0, title } }) }, []) const panel = useCallback( @@ -759,39 +231,9 @@ export function App({ gw }: { gw: GatewayClient }) { [sys] ) - const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { - setActivity(prev => { - const base = replaceLabel ? prev.filter(a => !sameToolTrailGroup(replaceLabel, a.text)) : prev - - if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) { - return base - } - - activityIdRef.current++ - - return [...base, { id: activityIdRef.current, text, tone }].slice(-8) - }) - }, []) - - const setTrail = (next: string[]) => { - turnToolsRef.current = next - - return next - } - - const pruneTransient = useCallback(() => { - setTurnTrail(prev => { - const next = prev.filter(l => !isTransientTrailLine(l)) - - return next.length === prev.length ? prev : setTrail(next) - }) - }, []) - - const pushTrail = useCallback((line: string) => { - setTurnTrail(prev => - prev.at(-1) === line ? prev : setTrail([...prev.filter(l => !isTransientTrailLine(l)), line].slice(-8)) - ) - }, []) + const pushActivity = turnActions.pushActivity + const pruneTransient = turnActions.pruneTransient + const pushTrail = turnActions.pushTrail const rpc = useCallback( async (method: string, params: Record = {}) => { @@ -812,16 +254,21 @@ export function App({ gw }: { gw: GatewayClient }) { [gw, sys] ) + const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + const answerClarify = useCallback( (answer: string) => { + const clarify = overlay.clarify + if (!clarify) { return } const label = toolTrailLabel('clarify') + const nextTrail = turnRefs.turnToolsRef.current.filter(line => !sameToolTrailGroup(label, line)) - setTrail(turnToolsRef.current.filter(l => !sameToolTrailGroup(label, l))) - setTurnTrail(turnToolsRef.current) + turnRefs.turnToolsRef.current = nextTrail + turnActions.setTurnTrail(nextTrail) rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { if (!r) { @@ -829,7 +276,7 @@ export function App({ gw }: { gw: GatewayClient }) { } if (answer) { - persistedToolLabelsRef.current.add(label) + turnRefs.persistedToolLabelsRef.current.add(label) appendMessage({ role: 'system', text: '', @@ -837,19 +284,19 @@ export function App({ gw }: { gw: GatewayClient }) { tools: [buildToolTrailLine('clarify', clarify.question)] }) appendMessage({ role: 'user', text: answer }) - setStatus('running…') + patchUiState({ status: 'running…' }) } else { sys('prompt cancelled') } - setClarify(null) + patchOverlayState({ clarify: null }) }) }, - [appendMessage, clarify, rpc, sys] + [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs] ) useEffect(() => { - if (!sid) { + if (!ui.sid) { return } @@ -859,15 +306,18 @@ export function App({ gw }: { gw: GatewayClient }) { }) rpc('config.get', { key: 'full' }).then((r: any) => { const display = r?.config?.display ?? {} + setBellOnComplete(!!display?.bell_on_complete) - setCompact(!!display?.tui_compact) - setStatusBar(display?.tui_statusbar !== false) - setDetailsMode(resolveDetailsMode(display)) + patchUiState({ + compact: !!display?.tui_compact, + detailsMode: resolveDetailsMode(display), + statusBar: display?.tui_statusbar !== false + }) }) - }, [rpc, sid]) + }, [rpc, ui.sid]) useEffect(() => { - if (!sid) { + if (!ui.sid) { return } @@ -877,7 +327,7 @@ export function App({ gw }: { gw: GatewayClient }) { if (configMtimeRef.current && next && next !== configMtimeRef.current) { configMtimeRef.current = next - rpc('reload.mcp', { session_id: sid }).then(r => { + rpc('reload.mcp', { session_id: ui.sid }).then(r => { if (!r) { return } @@ -886,10 +336,13 @@ export function App({ gw }: { gw: GatewayClient }) { }) rpc('config.get', { key: 'full' }).then((cfg: any) => { const display = cfg?.config?.display ?? {} + setBellOnComplete(!!display?.bell_on_complete) - setCompact(!!display?.tui_compact) - setStatusBar(display?.tui_statusbar !== false) - setDetailsMode(resolveDetailsMode(display)) + patchUiState({ + compact: !!display?.tui_compact, + detailsMode: resolveDetailsMode(display), + statusBar: display?.tui_statusbar !== false + }) }) } else if (!configMtimeRef.current && next) { configMtimeRef.current = next @@ -898,83 +351,57 @@ export function App({ gw }: { gw: GatewayClient }) { }, 5000) return () => clearInterval(id) - }, [pushActivity, rpc, sid]) + }, [pushActivity, rpc, ui.sid]) - const idle = () => { - endReasoningPhase() - setTools([]) - setTurnTrail([]) - setBusy(false) - setClarify(null) - setApproval(null) - setSudo(null) - setSecret(null) + const idle = turnActions.idle + const clearReasoning = turnActions.clearReasoning - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current) - streamTimerRef.current = null - } - - setStreaming('') - buf.current = '' - } - - const clearReasoning = () => { - if (reasoningTimerRef.current) { - clearTimeout(reasoningTimerRef.current) - reasoningTimerRef.current = null - } - - reasoningRef.current = '' - setReasoning('') - } - - const die = () => { + const die = useCallback(() => { gw.kill() exit() - } + }, [exit, gw]) - const clearIn = () => { - setInput('') - setInputBuf([]) - setQueueEdit(null) - setHistoryIdx(null) - historyDraftRef.current = '' - } - - const resetSession = () => { + const resetSession = useCallback(() => { idle() clearReasoning() setVoiceRecording(false) setVoiceProcessing(false) - setSid(null as any) // will be set by caller - setInfo(null) + patchUiState({ + bgTasks: new Set(), + info: null, + sid: null, + usage: ZERO + }) setHistoryItems([]) setMessages([]) setStickyPrompt('') - setPasteSnips([]) - setActivity([]) - setBgTasks(new Set()) - setUsage(ZERO) - turnToolsRef.current = [] - lastStatusNoteRef.current = '' - protocolWarnedRef.current = false - } + composerActions.setPasteSnips([]) + turnActions.setActivity([]) + turnRefs.turnToolsRef.current = [] + turnRefs.lastStatusNoteRef.current = '' + turnRefs.protocolWarnedRef.current = false + turnRefs.persistedToolLabelsRef.current.clear() + }, [clearReasoning, composerActions, idle, turnActions, turnRefs]) - const resetVisibleHistory = (info: SessionInfo | null = null) => { - idle() - clearReasoning() - setMessages([]) - setHistoryItems(info ? [introMsg(info)] : []) - setInfo(info) - setUsage(info?.usage ? { ...ZERO, ...info.usage } : ZERO) - setStickyPrompt('') - setPasteSnips([]) - setActivity([]) - setLastUserMsg('') - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - } + const resetVisibleHistory = useCallback( + (info: SessionInfo | null = null) => { + idle() + clearReasoning() + setMessages([]) + setHistoryItems(info ? [introMsg(info)] : []) + patchUiState({ + info, + usage: info?.usage ? { ...ZERO, ...info.usage } : ZERO + }) + setStickyPrompt('') + composerActions.setPasteSnips([]) + turnActions.setActivity([]) + setLastUserMsg('') + turnRefs.turnToolsRef.current = [] + turnRefs.persistedToolLabelsRef.current.clear() + }, + [clearReasoning, composerActions, idle, turnActions, turnRefs] + ) const trimLastExchange = (items: Msg[]) => { const q = [...items] @@ -992,7 +419,7 @@ export function App({ gw }: { gw: GatewayClient }) { const guardBusySessionSwitch = useCallback( (what = 'switch sessions') => { - if (!busyRef.current) { + if (!getUiState().busy) { return false } @@ -1018,30 +445,26 @@ export function App({ gw }: { gw: GatewayClient }) { const newSession = useCallback( async (msg?: string) => { - await closeSession(sidRef.current) + await closeSession(getUiState().sid) return rpc('session.create', { cols: colsRef.current }).then((r: any) => { if (!r) { - setStatus('ready') + patchUiState({ status: 'ready' }) return } resetSession() - setSid(r.session_id) setSessionStartedAt(Date.now()) - setStatus('ready') + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO + }) if (r.info) { - setInfo(r.info) - - if (r.info.usage) { - setUsage(prev => ({ ...prev, ...r.info.usage })) - } - setHistoryItems([introMsg(r.info)]) - } else { - setInfo(null) } if (r.info?.credential_warning) { @@ -1053,14 +476,14 @@ export function App({ gw }: { gw: GatewayClient }) { } }) }, - [closeSession, rpc, sys] + [closeSession, resetSession, rpc, sys] ) const resumeById = useCallback( (id: string) => { - setPicker(false) - setStatus('resuming…') - closeSession(sidRef.current === id ? null : sidRef.current).then(() => + patchOverlayState({ picker: false }) + patchUiState({ status: 'resuming…' }) + closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => gw .request('session.resume', { cols: colsRef.current, session_id: id }) .then((raw: any) => { @@ -1068,39 +491,38 @@ export function App({ gw }: { gw: GatewayClient }) { if (!r) { sys('error: invalid response: session.resume') - setStatus('ready') + patchUiState({ status: 'ready' }) return } resetSession() - setSid(r.session_id) setSessionStartedAt(Date.now()) - setInfo(r.info ?? null) const resumed = toTranscriptMessages(r.messages) - if (r.info?.usage) { - setUsage(prev => ({ ...prev, ...r.info.usage })) - } - setMessages(resumed) setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - setStatus('ready') + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO + }) }) .catch((e: Error) => { sys(`error: ${e.message}`) - setStatus('ready') + patchUiState({ status: 'ready' }) }) ) }, - [closeSession, gw, sys] + [closeSession, gw, resetSession, sys] ) // ── Paste pipeline ─────────────────────────────────────────────── const paste = useCallback( (quiet = false) => - rpc('clipboard.paste', { session_id: sid }).then((r: any) => { + rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => { if (!r) { return } @@ -1114,213 +536,54 @@ export function App({ gw }: { gw: GatewayClient }) { quiet || sys(r.message || 'No image found in clipboard') }), - [rpc, sid, sys] + [rpc, sys] ) - const handleTextPaste = useCallback( - ({ bracketed, cursor, hotkey, text, value }: PasteEvent) => { - if (hotkey) { - void paste(false) - - return null - } - - const cleanedText = stripTrailingPasteNewlines(text) - - if (!cleanedText || !/[^\n]/.test(cleanedText)) { - if (bracketed) { - void paste(true) - } - - return null - } - - const lineCount = cleanedText.split('\n').length - - if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { - return { - cursor: cursor + cleanedText.length, - value: value.slice(0, cursor) + cleanedText + value.slice(cursor) - } - } - - const label = pasteTokenLabel(cleanedText, lineCount) - const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' - const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' - const insert = `${lead}${label}${tail}` - - setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32)) - - return { - cursor: cursor + insert.length, - value: value.slice(0, cursor) + insert + value.slice(cursor) - } - }, - [paste] - ) + clipboardPasteRef.current = paste + const handleTextPaste = composerActions.handleTextPaste // ── Send ───────────────────────────────────────────────────────── - const send = (text: string) => { - const expandPasteSnips = (value: string) => { - const byLabel = new Map() + const send = useCallback( + (text: string) => { + const expandPasteSnips = (value: string) => { + const byLabel = new Map() - for (const item of pasteSnips) { - const list = byLabel.get(item.label) - list ? list.push(item.text) : byLabel.set(item.label, [item.text]) + for (const item of composerState.pasteSnips) { + const list = byLabel.get(item.label) + list ? list.push(item.text) : byLabel.set(item.label, [item.text]) + } + + return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) } - return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) - } + const startSubmit = (displayText: string, submitText: string) => { + const sid = getUiState().sid - const startSubmit = (displayText: string, submitText: string) => { - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - statusTimerRef.current = null - } - - setLastUserMsg(text) - appendMessage({ role: 'user', text: displayText }) - setBusy(true) - setStatus('running…') - buf.current = '' - interruptedRef.current = false - - gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { - sys(`error: ${e.message}`) - setStatus('ready') - setBusy(false) - }) - } - - gw.request('input.detect_drop', { session_id: sid, text }) - .then((r: any) => { - if (r?.matched) { - if (r.is_image) { - const meta = imageTokenMeta(r) - pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - } else { - pushActivity(`detected file: ${r.name}`) - } - - startSubmit(r.text || text, expandPasteSnips(r.text || text)) + if (!sid) { + sys('session not ready yet') return } - startSubmit(text, expandPasteSnips(text)) - }) - .catch(() => startSubmit(text, expandPasteSnips(text))) - } - - const shellExec = (cmd: string) => { - appendMessage({ role: 'user', text: `!${cmd}` }) - setBusy(true) - setStatus('running…') - - gw.request('shell.exec', { command: cmd }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: shell.exec') - - return + if (turnRefs.statusTimerRef.current) { + clearTimeout(turnRefs.statusTimerRef.current) + turnRefs.statusTimerRef.current = null } - const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() + setLastUserMsg(text) + appendMessage({ role: 'user', text: displayText }) + patchUiState({ busy: true, status: 'running…' }) + turnRefs.bufRef.current = '' + turnRefs.interruptedRef.current = false - if (out) { - sys(out) - } - - if (r.code !== 0 || !out) { - sys(`exit ${r.code}`) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - .finally(() => { - setStatus('ready') - setBusy(false) - }) - } - - const openEditor = () => { - const editor = process.env.EDITOR || process.env.VISUAL || 'vi' - const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') - - writeFileSync(file, [...inputBuf, input].join('\n')) - process.stdout.write('\x1b[?1049l') - const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' }) - process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') - - if (code === 0) { - const text = readFileSync(file, 'utf8').trimEnd() - - if (text) { - setInput('') - setInputBuf([]) - submit(text) - } - } - - try { - unlinkSync(file) - } catch { - /* noop */ - } - } - - const interpolate = (text: string, then: (result: string) => void) => { - setStatus('interpolating…') - const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] - - Promise.all( - matches.map(m => - gw - .request('shell.exec', { command: m[1]! }) - .then((raw: any) => { - const r = asRpcResult(raw) - - return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() - }) - .catch(() => '(error)') - ) - ).then(results => { - let out = text - - for (let i = matches.length - 1; i >= 0; i--) { - out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ busy: false, status: 'ready' }) + }) } - then(out) - }) - } - - const sendQueued = (text: string) => { - if (text.startsWith('!')) { - shellExec(text.slice(1).trim()) - - return - } - - if (hasInterpolation(text)) { - setBusy(true) - interpolate(text, send) - - return - } - - send(text) - } - - // ── Dispatch ───────────────────────────────────────────────────── - - const dispatchSubmission = useCallback( - (full: string) => { - if (!full.trim()) { - return - } + const sid = getUiState().sid if (!sid) { sys('session not ready yet') @@ -1328,63 +591,177 @@ export function App({ gw }: { gw: GatewayClient }) { return } - const clearInput = () => { - setInputBuf([]) - setInput('') - setHistoryIdx(null) - historyDraftRef.current = '' + gw.request('input.detect_drop', { session_id: sid, text }) + .then((r: any) => { + if (r?.matched) { + if (r.is_image) { + const meta = imageTokenMeta(r) + pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + } else { + pushActivity(`detected file: ${r.name}`) + } + + startSubmit(r.text || text, expandPasteSnips(r.text || text)) + + return + } + + startSubmit(text, expandPasteSnips(text)) + }) + .catch(() => startSubmit(text, expandPasteSnips(text))) + }, + [appendMessage, composerState.pasteSnips, gw, pushActivity, sys, turnRefs] + ) + + const shellExec = useCallback( + (cmd: string) => { + appendMessage({ role: 'user', text: `!${cmd}` }) + patchUiState({ busy: true, status: 'running…' }) + + gw.request('shell.exec', { command: cmd }) + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: shell.exec') + + return + } + + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() + + if (out) { + sys(out) + } + + if (r.code !== 0 || !out) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => { + patchUiState({ busy: false, status: 'ready' }) + }) + }, + [appendMessage, gw, sys] + ) + + const openEditor = composerActions.openEditor + + const interpolate = useCallback( + (text: string, then: (result: string) => void) => { + patchUiState({ status: 'interpolating…' }) + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + + Promise.all( + matches.map(m => + gw + .request('shell.exec', { command: m[1]! }) + .then((raw: any) => { + const r = asRpcResult(raw) + + return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() + }) + .catch(() => '(error)') + ) + ).then(results => { + let out = text + + for (let i = matches.length - 1; i >= 0; i--) { + out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) + } + + then(out) + }) + }, + [gw] + ) + + const sendQueued = useCallback( + (text: string) => { + if (text.startsWith('!')) { + shellExec(text.slice(1).trim()) + + return + } + + if (hasInterpolation(text)) { + patchUiState({ busy: true }) + interpolate(text, send) + + return + } + + send(text) + }, + [interpolate, send, shellExec] + ) + + // ── Dispatch ───────────────────────────────────────────────────── + + const dispatchSubmission = useCallback( + (full: string) => { + const live = getUiState() + + if (!full.trim()) { + return + } + + if (!live.sid) { + sys('session not ready yet') + + return } if (looksLikeSlashCommand(full)) { appendMessage({ role: 'system', text: full, kind: 'slash' }) - pushHistory(full) + composerActions.pushHistory(full) slashRef.current(full) - clearInput() + composerActions.clearIn() return } if (full.startsWith('!')) { - clearInput() + composerActions.clearIn() shellExec(full.slice(1).trim()) return } - clearInput() - - const editIdx = queueEditRef.current + composerActions.clearIn() + const editIdx = composerRefs.queueEditRef.current if (editIdx !== null) { - replaceQ(editIdx, full) - const picked = queueRef.current.splice(editIdx, 1)[0] - syncQueue() - setQueueEdit(null) + composerActions.replaceQueue(editIdx, full) + const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] + composerActions.syncQueue() + composerActions.setQueueEdit(null) - if (picked && busy && sid) { - queueRef.current.unshift(picked) - syncQueue() + if (picked && getUiState().busy && live.sid) { + composerRefs.queueRef.current.unshift(picked) + composerActions.syncQueue() return } - if (picked && sid) { + if (picked && live.sid) { sendQueued(picked) } return } - pushHistory(full) + composerActions.pushHistory(full) - if (busy) { - enqueue(full) + if (getUiState().busy) { + composerActions.enqueue(full) return } if (hasInterpolation(full)) { - setBusy(true) + patchUiState({ busy: true }) interpolate(full, send) return @@ -1393,644 +770,119 @@ export function App({ gw }: { gw: GatewayClient }) { send(full) }, // eslint-disable-next-line react-hooks/exhaustive-deps - [appendMessage, busy, enqueue, gw, pushHistory, sid] + [appendMessage, composerActions, composerRefs] ) // ── Input handling ─────────────────────────────────────────────── - - const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target - - const pagerPageSize = Math.max(5, (stdout?.rows ?? 24) - 6) - - useInput((ch, key) => { - if (isBlocked) { - if (pager) { - if (key.return || ch === ' ') { - const next = pager.offset + pagerPageSize - - if (next >= pager.lines.length) { - setPager(null) - } else { - setPager({ ...pager, offset: next }) - } - } else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') { - setPager(null) - } - - return - } - - if (ctrl(key, ch, 'c')) { - if (clarify) { - answerClarify('') - } else if (approval) { - rpc('approval.respond', { choice: 'deny', session_id: sid }).then(r => { - if (!r) { - return - } - - setApproval(null) - sys('denied') - }) - } else if (sudo) { - rpc('sudo.respond', { request_id: sudo.requestId, password: '' }).then(r => { - if (!r) { - return - } - - setSudo(null) - sys('sudo cancelled') - }) - } else if (secret) { - rpc('secret.respond', { request_id: secret.requestId, value: '' }).then(r => { - if (!r) { - return - } - - setSecret(null) - sys('secret entry cancelled') - }) - } else if (modelPicker) { - setModelPicker(false) - } else if (picker) { - setPicker(false) - } - } else if (key.escape && picker) { - setPicker(false) - } - - return - } - - if (completions.length && input && historyIdx === null && (key.upArrow || key.downArrow)) { - setCompIdx(i => (key.upArrow ? (i - 1 + completions.length) % completions.length : (i + 1) % completions.length)) - - return - } - - if (key.wheelUp) { - scrollWithSelection(-WHEEL_SCROLL_STEP) - - return - } - - if (key.wheelDown) { - scrollWithSelection(WHEEL_SCROLL_STEP) - - return - } - - if (key.pageUp || key.pageDown) { - const viewport = scrollRef.current?.getViewportHeight() ?? Math.max(6, (stdout?.rows ?? 24) - 8) - const step = Math.max(4, viewport - 2) - scrollWithSelection(key.pageUp ? -step : step) - - return - } - - if (key.tab && completions.length) { - const row = completions[compIdx] - - if (row?.text) { - const text = input.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text - setInput(input.slice(0, compReplace) + text) - } - - return - } - - if (key.upArrow && !inputBuf.length) { - if (queueRef.current.length) { - const idx = queueEditIdx === null ? 0 : (queueEditIdx + 1) % queueRef.current.length - setQueueEdit(idx) - setHistoryIdx(null) - setInput(queueRef.current[idx] ?? '') - } else if (historyRef.current.length) { - const idx = historyIdx === null ? historyRef.current.length - 1 : Math.max(0, historyIdx - 1) - - if (historyIdx === null) { - historyDraftRef.current = input - } - - setHistoryIdx(idx) - setQueueEdit(null) - setInput(historyRef.current[idx] ?? '') - } - - return - } - - if (key.downArrow && !inputBuf.length) { - if (queueRef.current.length) { - const idx = - queueEditIdx === null - ? queueRef.current.length - 1 - : (queueEditIdx - 1 + queueRef.current.length) % queueRef.current.length - - setQueueEdit(idx) - setHistoryIdx(null) - setInput(queueRef.current[idx] ?? '') - } else if (historyIdx !== null) { - const next = historyIdx + 1 - - if (next >= historyRef.current.length) { - setHistoryIdx(null) - setInput(historyDraftRef.current) - } else { - setHistoryIdx(next) - setInput(historyRef.current[next] ?? '') - } - } - - return - } - - if (ctrl(key, ch, 'c')) { - if (hasSelection) { - const copied = selection.copySelection() - - if (copied) { - sys('copied selection') - } - } else if (busy && sid) { - interruptedRef.current = true - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - const partial = (streaming || buf.current).trimStart() - partial ? appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) : sys('interrupted') - - idle() - clearReasoning() - setActivity([]) - turnToolsRef.current = [] - setStatus('interrupted') - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - setStatus('ready') - }, 1500) - } else if (input || inputBuf.length) { - clearIn() - } else { - return die() - } - - return - } - - if (ctrl(key, ch, 'd')) { - return die() - } - - if (ctrl(key, ch, 'l')) { - if (guardBusySessionSwitch()) { - return - } - - setStatus('forging session…') - newSession() - - return - } - - if (ctrl(key, ch, 'b')) { - if (voiceRecording) { - setVoiceRecording(false) - setVoiceProcessing(true) - rpc('voice.record', { action: 'stop' }) - .then((r: any) => { - if (!r) { - return - } - - const transcript = String(r?.text || '').trim() - - if (transcript) { - setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript)) - } else { - sys('voice: no speech detected') - } - }) - .catch((e: Error) => sys(`voice error: ${e.message}`)) - .finally(() => { - setVoiceProcessing(false) - setStatus('ready') - }) - } else { - rpc('voice.record', { action: 'start' }) - .then(r => { - if (!r) { - return - } - - setVoiceRecording(true) - setStatus('recording…') - }) - .catch((e: Error) => sys(`voice error: ${e.message}`)) - } - - return - } - - if (ctrl(key, ch, 'g')) { - return openEditor() - } + const { pagerPageSize } = useInputHandlers({ + actions: { + answerClarify, + appendMessage, + die, + dispatchSubmission, + guardBusySessionSwitch, + newSession, + sys + }, + composer: { + actions: composerActions, + refs: composerRefs, + state: composerState + }, + gateway, + terminal: { + hasSelection, + scrollRef, + scrollWithSelection, + selection, + stdout + }, + turn: { + actions: turnActions, + refs: turnRefs + }, + voice: { + recording: voiceRecording, + setProcessing: setVoiceProcessing, + setRecording: setVoiceRecording + }, + wheelStep: WHEEL_SCROLL_STEP }) // ── Gateway events ─────────────────────────────────────────────── - const onEvent = useCallback( - (ev: GatewayEvent) => { - if (ev.session_id && sidRef.current && ev.session_id !== sidRef.current && !ev.type.startsWith('gateway.')) { - return - } - - const p = ev.payload as any - - switch (ev.type) { - case 'gateway.ready': - if (p?.skin) { - setTheme( - fromSkin(p.skin.colors ?? {}, p.skin.branding ?? {}, p.skin.banner_logo ?? '', p.skin.banner_hero ?? '') - ) + const onEvent = useMemo( + () => + createGatewayEventHandler({ + composer: { + dequeue: composerActions.dequeue, + queueEditRef: composerRefs.queueEditRef, + sendQueued + }, + gateway, + session: { + STARTUP_RESUME_ID, + colsRef, + newSession, + resetSession, + setCatalog + }, + system: { + bellOnComplete, + stdout, + sys + }, + transcript: { + appendMessage, + setHistoryItems, + setMessages + }, + turn: { + actions: { + clearReasoning, + endReasoningPhase: turnActions.endReasoningPhase, + idle, + pruneTransient, + pulseReasoningStreaming: turnActions.pulseReasoningStreaming, + pushActivity, + pushTrail, + scheduleReasoning: turnActions.scheduleReasoning, + scheduleStreaming: turnActions.scheduleStreaming, + setActivity: turnActions.setActivity, + setStreaming: turnActions.setStreaming, + setTools: turnActions.setTools, + setTurnTrail: turnActions.setTurnTrail + }, + refs: { + bufRef: turnRefs.bufRef, + interruptedRef: turnRefs.interruptedRef, + lastStatusNoteRef: turnRefs.lastStatusNoteRef, + persistedToolLabelsRef: turnRefs.persistedToolLabelsRef, + protocolWarnedRef: turnRefs.protocolWarnedRef, + reasoningRef: turnRefs.reasoningRef, + statusTimerRef: turnRefs.statusTimerRef, + toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, + turnToolsRef: turnRefs.turnToolsRef } - - rpc('commands.catalog', {}) - .then((r: any) => { - if (!r?.pairs) { - return - } - - setCatalog({ - canon: (r.canon ?? {}) as Record, - categories: (r.categories ?? []) as SlashCatalog['categories'], - pairs: r.pairs as [string, string][], - skillCount: (r.skill_count ?? 0) as number, - sub: (r.sub ?? {}) as Record - }) - - if (r.warning) { - pushActivity(String(r.warning), 'warn') - } - }) - .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) - - if (STARTUP_RESUME_ID) { - setStatus('resuming…') - gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - throw new Error('invalid response: session.resume') - } - - resetSession() - setSid(r.session_id) - setInfo(r.info ?? null) - const resumed = toTranscriptMessages(r.messages) - - if (r.info?.usage) { - setUsage(prev => ({ ...prev, ...r.info.usage })) - } - - setMessages(resumed) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - setStatus('ready') - }) - .catch((e: unknown) => { - sys(`resume failed: ${rpcErrorMessage(e)}`) - setStatus('forging session…') - newSession('started a new session') - }) - } else { - setStatus('forging session…') - newSession() - } - - break - - case 'skin.changed': - if (p) { - setTheme(fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '')) - } - - break - - case 'session.info': - setInfo(p as SessionInfo) - - if (p?.usage) { - setUsage(prev => ({ ...prev, ...p.usage })) - } - - break - - case 'thinking.delta': - if (p && Object.prototype.hasOwnProperty.call(p, 'text')) { - setStatus(p.text ? String(p.text) : busyRef.current ? 'running…' : 'ready') - } - - break - - case 'message.start': - setBusy(true) - endReasoningPhase() - clearReasoning() - setActivity([]) - setTurnTrail([]) - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - - break - - case 'status.update': - if (p?.text) { - setStatus(p.text) - - if (p.kind && p.kind !== 'status') { - if (lastStatusNoteRef.current !== p.text) { - lastStatusNoteRef.current = p.text - pushActivity( - p.text, - p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' - ) - } - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - setStatus(busyRef.current ? 'running…' : 'ready') - }, 4000) - } - } - - break - - case 'gateway.stderr': - if (p?.line) { - const line = String(p.line).slice(0, 120) - const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn' - pushActivity(line, tone) - } - - break - - case 'gateway.start_timeout': - setStatus('gateway startup timeout') - pushActivity( - `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`, - 'error' - ) - - break - - case 'gateway.protocol_error': - setStatus('protocol warning') - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - setStatus(busyRef.current ? 'running…' : 'ready') - }, 4000) - - if (!protocolWarnedRef.current) { - protocolWarnedRef.current = true - pushActivity('protocol noise detected · /logs to inspect', 'warn') - } - - if (p?.preview) { - pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn') - } - - break - - case 'reasoning.delta': - if (p?.text) { - reasoningRef.current += p.text - scheduleReasoning() - pulseReasoningStreaming() - } - - break - - case 'tool.progress': - if (p?.preview) { - setTools(prev => { - const idx = prev.findIndex(t => t.name === p.name) - - return idx >= 0 - ? [...prev.slice(0, idx), { ...prev[idx]!, context: p.preview as string }, ...prev.slice(idx + 1)] - : prev - }) - } - - break - - case 'tool.generating': - if (p?.name) { - pushTrail(`drafting ${p.name}…`) - } - - break - - case 'tool.start': - pruneTransient() - endReasoningPhase() - setTools(prev => [ - ...prev, - { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } - ]) - - break - case 'tool.complete': { - toolCompleteRibbonRef.current = null - setTools(prev => { - const done = prev.find(t => t.id === p.tool_id) - const name = done?.name ?? p.name - const label = toolTrailLabel(name) - - const line = buildToolTrailLine( - name, - done?.context || '', - !!p.error, - (p.error as string) || (p.summary as string) || '' - ) - - toolCompleteRibbonRef.current = { label, line } - const remaining = prev.filter(t => t.id !== p.tool_id) - const next = [...turnToolsRef.current.filter(s => !sameToolTrailGroup(label, s)), line] - - if (!remaining.length) { - next.push('analyzing tool output…') - } - - const pruned = next.slice(-8) - turnToolsRef.current = pruned - setTurnTrail(pruned) - - return remaining - }) - - if (p?.inline_diff) { - sys(p.inline_diff as string) - } - - break } - - case 'clarify.request': - setClarify({ choices: p.choices, question: p.question, requestId: p.request_id }) - setStatus('waiting for input…') - - break - - case 'approval.request': - setApproval({ command: p.command, description: p.description }) - setStatus('approval needed') - - break - - case 'sudo.request': - setSudo({ requestId: p.request_id }) - setStatus('sudo password needed') - - break - - case 'secret.request': - setSecret({ requestId: p.request_id, prompt: p.prompt, envVar: p.env_var }) - setStatus('secret input needed') - - break - - case 'background.complete': - setBgTasks(prev => { - const next = new Set(prev) - next.delete(p.task_id) - - return next - }) - sys(`[bg ${p.task_id}] ${p.text}`) - - break - - case 'btw.complete': - setBgTasks(prev => { - const next = new Set(prev) - next.delete('btw:x') - - return next - }) - sys(`[btw] ${p.text}`) - - break - - case 'message.delta': - pruneTransient() - endReasoningPhase() - - if (p?.text && !interruptedRef.current) { - buf.current = p.rendered ?? buf.current + p.text - scheduleStreaming() - } - - break - case 'message.complete': { - const wasInterrupted = interruptedRef.current - const savedReasoning = reasoningRef.current.trim() - const persisted = persistedToolLabelsRef.current - - const savedTools = turnToolsRef.current.filter( - l => isToolTrailResultLine(l) && ![...persisted].some(p => sameToolTrailGroup(p, l)) - ) - - const finalText = (p?.rendered ?? p?.text ?? buf.current).trimStart() - - idle() - clearReasoning() - setStreaming('') - - if (!wasInterrupted) { - appendMessage({ - role: 'assistant', - text: finalText, - thinking: savedReasoning || undefined, - tools: savedTools.length ? savedTools : undefined - }) - - if (bellOnComplete && stdout?.isTTY) { - stdout.write('\x07') - } - } - - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - setActivity([]) - - buf.current = '' - setStatus('ready') - - if (p?.usage) { - setUsage(p.usage) - } - - if (queueEditRef.current !== null) { - break - } - - const next = dequeue() - - if (next) { - sendQueued(next) - } - - break - } - - case 'error': - idle() - clearReasoning() - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - statusTimerRef.current = null - } - - pushActivity(String(p?.message || 'unknown error'), 'error') - sys(`error: ${p?.message}`) - setStatus('ready') - - break - } - }, + }), [ appendMessage, bellOnComplete, clearReasoning, - dequeue, - endReasoningPhase, - gw, + composerActions, + composerRefs, + gateway, + idle, newSession, pruneTransient, - pulseReasoningStreaming, pushActivity, pushTrail, - rpc, - scheduleReasoning, - scheduleStreaming, + resetSession, sendQueued, sys, + turnActions, + turnRefs, stdout ] ) @@ -2041,9 +893,7 @@ export function App({ gw }: { gw: GatewayClient }) { const handler = (ev: GatewayEvent) => onEventRef.current(ev) const exitHandler = () => { - setStatus('gateway exited') - setSid(null) - setBusy(false) + patchUiState({ busy: false, sid: null, status: 'gateway exited' }) pushActivity('gateway exited · /logs to inspect', 'error') sys('error: gateway exited') } @@ -2060,1073 +910,92 @@ export function App({ gw }: { gw: GatewayClient }) { }, [gw, pushActivity, sys]) // ── Slash commands ─────────────────────────────────────────────── + // Always current via ref — no useMemo deps duplication needed. - const slash = useCallback( - (cmd: string): boolean => { - const [rawName, ...rest] = cmd.slice(1).split(/\s+/) - const name = rawName.toLowerCase() - const arg = rest.join(' ') - - switch (name) { - case 'help': { - const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }) => ({ - title: catName, - rows: pairs - })) - - if (catalog?.skillCount) { - sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) - } - - sections.push({ - title: 'TUI', - rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']] - }) - - sections.push({ title: 'Hotkeys', rows: HOTKEYS }) - - panel('Commands', sections) - - return true - } - - case 'quit': - - case 'exit': - - case 'q': - die() - - return true - - case 'clear': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - setStatus('forging session…') - newSession() - - return true - - case 'new': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - setStatus('forging session…') - newSession('new session started') - - return true - - case 'resume': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - if (arg) { - resumeById(arg) - } else { - setPicker(true) - } - - return true - - case 'compact': - if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { - sys('usage: /compact [on|off|toggle]') - - return true - } - - { - const mode = arg.trim().toLowerCase() - setCompact(current => { - const next = mode === 'on' ? true : mode === 'off' ? false : !current - rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) - - return next - }) - } - - return true - - case 'details': - - case 'detail': - if (!arg) { - rpc('config.get', { key: 'details_mode' }) - .then((r: any) => { - const mode = parseDetailsMode(r?.value) ?? detailsMode - setDetailsMode(mode) - sys(`details: ${mode}`) - }) - .catch(() => sys(`details: ${detailsMode}`)) - - return true - } - - { - const mode = arg.trim().toLowerCase() - - if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { - sys('usage: /details [hidden|collapsed|expanded|cycle]') - - return true - } - - const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(detailsMode) : (mode as DetailsMode) - setDetailsMode(next) - rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) - sys(`details: ${next}`) - } - - return true - case 'copy': { - if (!arg && hasSelection) { - const copied = selection.copySelection() - - if (copied) { - sys('copied selection') - - return true - } - } - - const all = messages.filter(m => m.role === 'assistant') - - if (arg && Number.isNaN(parseInt(arg, 10))) { - sys('usage: /copy [number]') - - return true - } - - const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] - - if (!target) { - sys('nothing to copy') - - return true - } - - writeOsc52Clipboard(target.text) - sys('sent OSC52 copy sequence (terminal support required)') - - return true - } - - case 'paste': - if (!arg) { - paste() - - return true - } - - sys('usage: /paste') - - return true - case 'logs': { - const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) - logText ? page(logText, 'Logs') : sys('no gateway logs') - - return true - } - - case 'statusbar': - - case 'sb': - if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { - sys('usage: /statusbar [on|off|toggle]') - - return true - } - - setStatusBar(current => { - const mode = arg.trim().toLowerCase() - const next = mode === 'on' ? true : mode === 'off' ? false : !current - rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) - - return next - }) - - return true - - case 'queue': - if (!arg) { - sys(`${queueRef.current.length} queued message(s)`) - - return true - } - - enqueue(arg) - sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) - - return true - - case 'undo': - if (!sid) { - sys('nothing to undo') - - return true - } - - rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - if (r.removed > 0) { - setMessages(prev => trimLastExchange(prev)) - setHistoryItems(prev => trimLastExchange(prev)) - sys(`undid ${r.removed} messages`) - } else { - sys('nothing to undo') - } - }) - - return true - - case 'retry': - if (!lastUserMsg) { - sys('nothing to retry') - - return true - } - - if (sid) { - rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - if (r.removed <= 0) { - sys('nothing to retry') - - return - } - - setMessages(prev => trimLastExchange(prev)) - setHistoryItems(prev => trimLastExchange(prev)) - send(lastUserMsg) - }) - - return true - } - - send(lastUserMsg) - - return true - - case 'background': - - case 'bg': - if (!arg) { - sys('/background ') - - return true - } - - rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => { - if (!r?.task_id) { - return - } - - setBgTasks(prev => new Set(prev).add(r.task_id)) - sys(`bg ${r.task_id} started`) - }) - - return true - - case 'btw': - if (!arg) { - sys('/btw ') - - return true - } - - rpc('prompt.btw', { session_id: sid, text: arg }).then(r => { - if (!r) { - return - } - - setBgTasks(prev => new Set(prev).add('btw:x')) - sys('btw running…') - }) - - return true - - case 'model': - if (guardBusySessionSwitch('change models')) { - return true - } - - if (!arg) { - setModelPicker(true) - } else { - rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { - if (!r) { - return - } - - if (!r.value) { - sys('error: invalid response: model switch') - - return - } - - sys(`model → ${r.value}`) - maybeWarn(r) - setInfo(prev => (prev ? { ...prev, model: r.value } : { model: r.value, skills: {}, tools: {} })) - }) - } - - return true - - case 'image': - rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { - if (!r) { - return - } - - const meta = imageTokenMeta(r) - sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - - if (r?.remainder) { - setInput(r.remainder) - } - }) - - return true - - case 'provider': - gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => { - page( - r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', - 'Provider' - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - case 'skin': - if (arg) { - rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`skin → ${r.value}`) - }) - } else { - rpc('config.get', { key: 'skin' }).then((r: any) => { - if (!r) { - return - } - - sys(`skin: ${r.value || 'default'}`) - }) - } - - return true - - case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { - if (!r) { - return - } - - sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - }) - - return true - - case 'reasoning': - if (!arg) { - rpc('config.get', { key: 'reasoning' }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) - }) - } else { - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`reasoning: ${r.value}`) - }) - } - - return true - - case 'verbose': - rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`verbose: ${r.value}`) - }) - - return true - - case 'personality': - if (arg) { - rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { - if (!r) { - return - } - - if (r.history_reset) { - resetVisibleHistory(r.info ?? null) - } - - sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) - maybeWarn(r) - }) - } else { - gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => { - panel('Personality', [ - { - text: r?.warning - ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` - : r?.output || '(no output)' - } - ]) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } - - return true - - case 'compress': - rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { - if (!r) { - return - } - - if (Array.isArray(r.messages)) { - const resumed = toTranscriptMessages(r.messages) - setMessages(resumed) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - } - - if (r.info) { - setInfo(r.info) - } - - if (r.usage) { - setUsage(prev => ({ ...prev, ...r.usage })) - } - - if ((r.removed ?? 0) <= 0) { - sys('nothing to compress') - - return - } - - sys(`compressed ${r.removed} messages${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) - }) - - return true - - case 'stop': - rpc('process.stop', {}).then((r: any) => { - if (!r) { - return - } - - sys(`killed ${r.killed ?? 0} registered process(es)`) - }) - - return true - - case 'branch': - - case 'fork': - { - const prevSid = sid - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (r?.session_id) { - void closeSession(prevSid) - setSid(r.session_id) - setSessionStartedAt(Date.now()) - setHistoryItems([]) - setMessages([]) - sys(`branched → ${r.title}`) - } - }) - } - - return true - - case 'reload-mcp': - - case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then(r => { - if (!r) { - return - } - - sys('MCP reloaded') - }) - - return true - - case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { - if (!r) { - return - } - - sys(`title: ${r.title || '(none)'}`) - }) - - return true - - case 'usage': - rpc('session.usage', { session_id: sid }).then((r: any) => { - if (r) { - setUsage({ input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 }) - } - - if (!r?.calls) { - sys('no API calls yet') - - return - } - - const f = (v: number) => (v ?? 0).toLocaleString() - - const cost = - r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null - - const rows: [string, string][] = [ - ['Model', r.model ?? ''], - ['Input tokens', f(r.input)], - ['Cache read tokens', f(r.cache_read)], - ['Cache write tokens', f(r.cache_write)], - ['Output tokens', f(r.output)], - ['Total tokens', f(r.total)], - ['API calls', f(r.calls)] - ] - - if (cost) { - rows.push(['Cost', cost]) - } - - const sections: PanelSection[] = [{ rows }] - - if (r.context_max) { - sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) - } - - if (r.compressions) { - sections.push({ text: `Compressions: ${r.compressions}` }) - } - - panel('Usage', sections) - }) - - return true - - case 'save': - rpc('session.save', { session_id: sid }).then((r: any) => { - if (!r?.file) { - return - } - - sys(`saved: ${r.file}`) - }) - - return true - - case 'history': - rpc('session.history', { session_id: sid }).then((r: any) => { - if (typeof r?.count !== 'number') { - return - } - - sys(`${r.count} messages`) - }) - - return true - - case 'profile': - rpc('config.get', { key: 'profile' }).then((r: any) => { - if (!r) { - return - } - - const text = r.display || r.home || '(unknown profile)' - const lines = text.split('\n').filter(Boolean) - - if (lines.length <= 2) { - panel('Profile', [{ text }]) - } else { - page(text, 'Profile') - } - }) - - return true - - case 'voice': - rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { - if (!r) { - return - } - - setVoiceEnabled(!!r?.enabled) - sys(`voice: ${r.enabled ? 'on' : 'off'}`) - }) - - return true - - case 'insights': - rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { - if (!r) { - return - } - - panel('Insights', [ - { - rows: [ - ['Period', `${r.days} days`], - ['Sessions', `${r.sessions}`], - ['Messages', `${r.messages}`] - ] - } - ]) - }) - - return true - case 'rollback': { - const [sub, ...rArgs] = (arg || 'list').split(/\s+/) - - if (!sub || sub === 'list') { - rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - if (!r.checkpoints?.length) { - return sys('no checkpoints') - } - - panel('Checkpoints', [ - { - rows: r.checkpoints.map( - (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] - ) - } - ]) - }) - } else { - const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub - - const filePath = - sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim() - - rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { - session_id: sid, - hash, - ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => { - if (!r) { - return - } - - sys(r.rendered || r.diff || r.message || 'done') - }) - } - - return true - } - - case 'browser': { - const [act, ...bArgs] = (arg || 'status').split(/\s+/) - rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => { - if (!r) { - return - } - - sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - }) - - return true - } - - case 'plugins': - rpc('plugins.list', {}).then((r: any) => { - if (!r) { - return - } - - if (!r.plugins?.length) { - return sys('no plugins') - } - - panel('Plugins', [ - { - items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) - } - ]) - }) - - return true - case 'skills': { - const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean) - - if (!sub || sub === 'list') { - rpc('skills.manage', { action: 'list' }).then((r: any) => { - if (!r) { - return - } - - const sk = r.skills as Record | undefined - - if (!sk || !Object.keys(sk).length) { - return sys('no skills installed') - } - - panel( - 'Installed Skills', - Object.entries(sk).map(([cat, names]) => ({ - title: cat, - items: names as string[] - })) - ) - }) - - return true - } - - if (sub === 'browse') { - const pg = parseInt(sArgs[0] ?? '1', 10) || 1 - rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { - if (!r) { - return - } - - if (!r.items?.length) { - return sys('no skills found in the hub') - } - - const sections: PanelSection[] = [ - { - rows: r.items.map( - (s: any) => - [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [ - string, - string - ] - ) - } - ] - - if (r.page < r.total_pages) { - sections.push({ text: `/skills browse ${r.page + 1} → next page` }) - } - - if (r.page > 1) { - sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) - } - - panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) - }) - - return true - } - - gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` - : r?.output || '/skills: no output' - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - case 'agents': - - case 'tasks': - rpc('agents.list', {}) - .then((r: any) => { - if (!r) { - return - } - - const procs = r.processes ?? [] - const running = procs.filter((p: any) => p.status === 'running') - const finished = procs.filter((p: any) => p.status !== 'running') - const sections: PanelSection[] = [] - - if (running.length) { - sections.push({ - title: `Running (${running.length})`, - rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } - - if (finished.length) { - sections.push({ - title: `Finished (${finished.length})`, - rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } - - if (!sections.length) { - sections.push({ text: 'No active processes' }) - } - - panel('Agents', sections) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - case 'cron': - if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }) - .then((r: any) => { - if (!r) { - return - } - - const jobs = r.jobs ?? [] - - if (!jobs.length) { - return sys('no scheduled jobs') - } - - panel('Cron', [ - { - rows: jobs.map( - (j: any) => - [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string] - ) - } - ]) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } else { - gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } - - return true - - case 'config': - rpc('config.show', {}) - .then((r: any) => { - if (!r) { - return - } - - panel( - 'Config', - (r.sections ?? []).map((s: any) => ({ - title: s.title, - rows: s.rows - })) - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - case 'tools': - rpc('tools.list', { session_id: sid }) - .then((r: any) => { - if (!r) { - return - } - - if (!r.toolsets?.length) { - return sys('no tools') - } - - panel( - 'Tools', - r.toolsets.map((ts: any) => ({ - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, - items: ts.tools - })) - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - case 'toolsets': - rpc('toolsets.list', { session_id: sid }) - .then((r: any) => { - if (!r) { - return - } - - if (!r.toolsets?.length) { - return sys('no toolsets') - } - - panel('Toolsets', [ - { - rows: r.toolsets.map( - (ts: any) => - [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ - string, - string - ] - ) - } - ]) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - default: - gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}` - : r?.output || `/${name}: no output` - ) - }) - .catch(() => { - gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) - .then((raw: any) => { - const d = asRpcResult(raw) - - if (!d?.type) { - sys('error: invalid response: command.dispatch') - - return - } - - if (d.type === 'exec') { - sys(d.output || '(no output)') - } else if (d.type === 'alias') { - slash(`/${d.target}${arg ? ' ' + arg : ''}`) - } else if (d.type === 'plugin') { - sys(d.output || '(no output)') - } else if (d.type === 'skill') { - sys(`⚡ loading skill: ${d.name}`) - - if (typeof d.message === 'string' && d.message.trim()) { - send(d.message) - } else { - sys(`/${name}: skill payload missing message`) - } - } - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - }) - - return true - } - }, - [ - catalog, - compact, - detailsMode, - guardBusySessionSwitch, - gw, + slashRef.current = createSlashHandler({ + composer: { + enqueue: composerActions.enqueue, hasSelection, + paste, + queueRef: composerRefs.queueRef, + selection, + setInput: composerActions.setInput + }, + gateway, + local: { + catalog, lastUserMsg, maybeWarn, - messages, + messages + }, + session: { + closeSession, + die, + guardBusySessionSwitch, newSession, + resetVisibleHistory, + resumeById, + setSessionStartedAt + }, + transcript: { page, panel, - pushActivity, - rpc, - resetVisibleHistory, - selection, send, - sid, - statusBar, - sys - ] - ) - - slashRef.current = slash + setHistoryItems, + setMessages, + sys, + trimLastExchange + }, + voice: { + setVoiceEnabled + } + }) // ── Submit ─────────────────────────────────────────────────────── const submit = useCallback( (value: string) => { - if (value.startsWith('/') && completions.length) { - const row = completions[compIdx] + if (value.startsWith('/') && composerState.completions.length) { + const row = composerState.completions[composerState.compIdx] if (row?.text) { const text = - value.startsWith('/') && row.text.startsWith('/') && compReplace > 0 ? row.text.slice(1) : row.text + value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0 + ? row.text.slice(1) + : row.text - const next = value.slice(0, compReplace) + text + const next = value.slice(0, composerState.compReplace) + text if (next !== value) { - setInput(next) + composerActions.setInput(next) return } } } - if (!value.trim() && !inputBuf.length) { + if (!value.trim() && !composerState.inputBuf.length) { + const live = getUiState() const now = Date.now() const dbl = now - lastEmptyAt.current < 450 lastEmptyAt.current = now - if (dbl && busy && sid) { - interruptedRef.current = true - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - const partial = (streaming || buf.current).trimStart() - - if (partial) { - appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) - } else { - sys('interrupted') - } - - idle() - clearReasoning() - setActivity([]) - turnToolsRef.current = [] - setStatus('interrupted') - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - setStatus('ready') - }, 1500) + if (dbl && live.busy && live.sid) { + turnActions.interruptTurn({ + appendMessage, + gw, + sid: live.sid, + sys + }) return } - if (dbl && queueRef.current.length) { - const next = dequeue() + if (dbl && composerRefs.queueRef.current.length) { + const next = composerActions.dequeue() - if (next && sid) { - setQueueEdit(null) + if (next && live.sid) { + composerActions.setQueueEdit(null) dispatchSubmission(next) } } @@ -3137,332 +1006,165 @@ export function App({ gw }: { gw: GatewayClient }) { lastEmptyAt.current = 0 if (value.endsWith('\\')) { - setInputBuf(prev => [...prev, value.slice(0, -1)]) - setInput('') + composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) + composerActions.setInput('') return } - dispatchSubmission([...inputBuf, value].join('\n')) + dispatchSubmission([...composerState.inputBuf, value].join('\n')) }, - [compIdx, compReplace, completions, dequeue, dispatchSubmission, inputBuf, sid] + [ + appendMessage, + composerActions, + composerRefs, + composerState, + dispatchSubmission, + gw, + sys, + turnActions + ] ) + submitRef.current = submit + // ── Derived ────────────────────────────────────────────────────── const statusColor = - status === 'ready' - ? theme.color.ok - : status.startsWith('error') - ? theme.color.error - : status === 'interrupted' - ? theme.color.warn - : theme.color.dim + ui.status === 'ready' + ? ui.theme.color.ok + : ui.status.startsWith('error') + ? ui.theme.color.error + : ui.status === 'interrupted' + ? ui.theme.color.warn + : ui.theme.color.dim - const durationLabel = sid ? fmtDuration(clockNow - sessionStartedAt) : '' + const durationLabel = ui.sid ? fmtDuration(clockNow - sessionStartedAt) : '' const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` - const cwdLabel = shortCwd(info?.cwd || process.env.HERMES_CWD || process.cwd()) - const showStreamingArea = Boolean(streaming) - const visibleHistory = virtualRows.slice(virtualHistory.start, virtualHistory.end) + const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) + const showStreamingArea = Boolean(turnState.streaming) const showStickyPrompt = !!stickyPrompt - const hasReasoning = Boolean(reasoning.trim()) + const hasReasoning = Boolean(turnState.reasoning.trim()) const showProgressArea = - detailsMode === 'hidden' - ? activity.some(i => i.tone !== 'info') - : Boolean(busy || tools.length || turnTrail.length || hasReasoning || activity.length) + ui.detailsMode === 'hidden' + ? turnState.activity.some(item => item.tone !== 'info') + : Boolean( + ui.busy || turnState.tools.length || turnState.turnTrail.length || hasReasoning || turnState.activity.length + ) + + const answerApproval = useCallback( + (choice: string) => { + rpc('approval.respond', { choice, session_id: ui.sid }).then(r => { + if (!r) { + return + } + + patchOverlayState({ approval: null }) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + patchUiState({ status: 'running…' }) + }) + }, + [rpc, sys, ui.sid] + ) + + const answerSudo = useCallback( + (pw: string) => { + if (!overlay.sudo) { + return + } + + rpc('sudo.respond', { request_id: overlay.sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + patchOverlayState({ sudo: null }) + patchUiState({ status: 'running…' }) + }) + }, + [overlay.sudo, rpc] + ) + + const answerSecret = useCallback( + (value: string) => { + if (!overlay.secret) { + return + } + + rpc('secret.respond', { request_id: overlay.secret.requestId, value }).then(r => { + if (!r) { + return + } + + patchOverlayState({ secret: null }) + patchUiState({ status: 'running…' }) + }) + }, + [overlay.secret, rpc] + ) + + const onModelSelect = useCallback((value: string) => { + patchOverlayState({ modelPicker: false }) + slashRef.current(`/model ${value}`) + }, []) // ── Render ─────────────────────────────────────────────────────── return ( - - - - - - {virtualHistory.topSpacer > 0 ? : null} - - {visibleHistory.map(row => ( - - {row.msg.kind === 'intro' && row.msg.info ? ( - - - - - ) : row.msg.kind === 'panel' && row.msg.panelData ? ( - - ) : ( - - )} - - ))} - - {virtualHistory.bottomSpacer > 0 ? : null} - - {showProgressArea && ( - - )} - - {showStreamingArea && ( - - )} - - - - - - - - - - - - - - {bgTasks.size > 0 && ( - - {bgTasks.size} background {bgTasks.size === 1 ? 'task' : 'tasks'} running - - )} - - {showStickyPrompt ? ( - - - {stickyPrompt} - - ) : ( - - )} - - - {statusBar && ( - - )} - - {(clarify || approval || sudo || secret || picker || modelPicker || pager || completions.length > 0) && ( - - {clarify && ( - - answerClarify('')} - req={clarify} - t={theme} - /> - - )} - - {approval && ( - - { - rpc('approval.respond', { choice, session_id: sid }).then(r => { - if (!r) { - return - } - - setApproval(null) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - setStatus('running…') - }) - }} - req={approval} - t={theme} - /> - - )} - - {sudo && ( - - { - rpc('sudo.respond', { request_id: sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - - setSudo(null) - setStatus('running…') - }) - }} - t={theme} - /> - - )} - - {secret && ( - - { - rpc('secret.respond', { request_id: secret.requestId, value: val }).then(r => { - if (!r) { - return - } - - setSecret(null) - setStatus('running…') - }) - }} - sub={`for ${secret.envVar}`} - t={theme} - /> - - )} - - {picker && ( - - setPicker(false)} onSelect={resumeById} t={theme} /> - - )} - - {modelPicker && ( - - setModelPicker(false)} - onSelect={value => { - setModelPicker(false) - slash(`/model ${value}`) - }} - sessionId={sid} - t={theme} - /> - - )} - - {pager && ( - - - {pager.title && ( - - - {pager.title} - - - )} - - {pager.lines.slice(pager.offset, pager.offset + pagerPageSize).map((line, i) => ( - {line} - ))} - - - - {pager.offset + pagerPageSize < pager.lines.length - ? `Enter/Space for more · q to close (${Math.min(pager.offset + pagerPageSize, pager.lines.length)}/${pager.lines.length})` - : `end · q to close (${pager.lines.length} lines)`} - - - - - )} - - {!!completions.length && ( - - - {completions.slice(Math.max(0, compIdx - 8), compIdx + 8).map((item, i) => { - const active = Math.max(0, compIdx - 8) + i === compIdx - - const bg = active ? theme.color.dim : undefined - const fg = theme.color.cornsilk - - return ( - - - {' '} - {item.display} - - - {item.meta ? ( - - {' '} - {item.meta} - - ) : null} - - ) - })} - - - )} - - )} - - - {!isBlocked && ( - - {inputBuf.map((line, i) => ( - - - {i === 0 ? `${theme.brand.prompt} ` : ' '} - - - {line || ' '} - - ))} - - - - - {inputBuf.length ? ' ' : `${theme.brand.prompt} `} - - - - - - - )} - - {!empty && !sid && ⚕ {status}} - - - + + + ) } diff --git a/ui-tui/src/app/constants.ts b/ui-tui/src/app/constants.ts new file mode 100644 index 0000000000..335e58d82f --- /dev/null +++ b/ui-tui/src/app/constants.ts @@ -0,0 +1,15 @@ +import { PLACEHOLDERS } from '../constants.js' +import { pick } from '../lib/text.js' + +export const PLACEHOLDER = pick(PLACEHOLDERS) +export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() + +export const LARGE_PASTE = { chars: 8000, lines: 80 } +export const MAX_HISTORY = 800 +export const REASONING_PULSE_MS = 700 +export const STREAM_BATCH_MS = 16 +export const WHEEL_SCROLL_STEP = 3 +export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test( + (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase() +) +export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts new file mode 100644 index 0000000000..8c3158017c --- /dev/null +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -0,0 +1,487 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' + +import type { GatewayEvent } from '../gatewayClient.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { buildToolTrailLine, isToolTrailResultLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import { fromSkin } from '../theme.js' +import type { Msg, SlashCatalog } from '../types.js' + +import { introMsg, toTranscriptMessages } from './helpers.js' +import type { GatewayServices } from './interfaces.js' +import { patchOverlayState } from './overlayStore.js' +import { getUiState, patchUiState } from './uiStore.js' +import type { TurnActions, TurnRefs } from './useTurnState.js' + +export interface GatewayEventHandlerContext { + composer: { + dequeue: () => string | undefined + queueEditRef: MutableRefObject + sendQueued: (text: string) => void + } + gateway: GatewayServices + session: { + STARTUP_RESUME_ID: string + colsRef: MutableRefObject + newSession: (msg?: string) => void + resetSession: () => void + setCatalog: Dispatch> + } + system: { + bellOnComplete: boolean + stdout?: NodeJS.WriteStream + sys: (text: string) => void + } + transcript: { + appendMessage: (msg: Msg) => void + setHistoryItems: Dispatch> + setMessages: Dispatch> + } + turn: { + actions: Pick< + TurnActions, + | 'clearReasoning' + | 'endReasoningPhase' + | 'idle' + | 'pruneTransient' + | 'pulseReasoningStreaming' + | 'pushActivity' + | 'pushTrail' + | 'scheduleReasoning' + | 'scheduleStreaming' + | 'setActivity' + | 'setStreaming' + | 'setTools' + | 'setTurnTrail' + > + refs: Pick< + TurnRefs, + | 'bufRef' + | 'interruptedRef' + | 'lastStatusNoteRef' + | 'persistedToolLabelsRef' + | 'protocolWarnedRef' + | 'reasoningRef' + | 'statusTimerRef' + | 'toolCompleteRibbonRef' + | 'turnToolsRef' + > + } +} + +export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { + const { dequeue, queueEditRef, sendQueued } = ctx.composer + const { gw, rpc } = ctx.gateway + const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session + const { bellOnComplete, stdout, sys } = ctx.system + const { appendMessage, setHistoryItems, setMessages } = ctx.transcript + + const { + clearReasoning, + endReasoningPhase, + idle, + pruneTransient, + pulseReasoningStreaming, + pushActivity, + pushTrail, + scheduleReasoning, + scheduleStreaming, + setActivity, + setStreaming, + setTools, + setTurnTrail + } = ctx.turn.actions + + const { + bufRef, + interruptedRef, + lastStatusNoteRef, + persistedToolLabelsRef, + protocolWarnedRef, + reasoningRef, + statusTimerRef, + toolCompleteRibbonRef, + turnToolsRef + } = ctx.turn.refs + + return (ev: GatewayEvent) => { + const sid = getUiState().sid + + if (ev.session_id && sid && ev.session_id !== sid && !ev.type.startsWith('gateway.')) { + return + } + + const p = ev.payload as any + + switch (ev.type) { + case 'gateway.ready': + if (p?.skin) { + patchUiState({ + theme: fromSkin( + p.skin.colors ?? {}, + p.skin.branding ?? {}, + p.skin.banner_logo ?? '', + p.skin.banner_hero ?? '' + ) + }) + } + + rpc('commands.catalog', {}) + .then((r: any) => { + if (!r?.pairs) { + return + } + + setCatalog({ + canon: (r.canon ?? {}) as Record, + categories: (r.categories ?? []) as any, + pairs: r.pairs as [string, string][], + skillCount: (r.skill_count ?? 0) as number, + sub: (r.sub ?? {}) as Record + }) + + if (r.warning) { + pushActivity(String(r.warning), 'warn') + } + }) + .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) + + if (STARTUP_RESUME_ID) { + patchUiState({ status: 'resuming…' }) + gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + throw new Error('invalid response: session.resume') + } + + resetSession() + const resumed = toTranscriptMessages(r.messages) + + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ?? getUiState().usage + }) + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + }) + .catch((e: unknown) => { + sys(`resume failed: ${rpcErrorMessage(e)}`) + patchUiState({ status: 'forging session…' }) + newSession('started a new session') + }) + } else { + patchUiState({ status: 'forging session…' }) + newSession() + } + + break + + case 'skin.changed': + if (p) { + patchUiState({ + theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '') + }) + } + + break + + case 'session.info': + patchUiState(state => ({ + ...state, + info: p as any, + usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage + })) + + break + + case 'thinking.delta': + if (p && Object.prototype.hasOwnProperty.call(p, 'text')) { + patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' }) + } + + break + + case 'message.start': + patchUiState({ busy: true }) + endReasoningPhase() + clearReasoning() + setActivity([]) + setTurnTrail([]) + turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() + + break + + case 'status.update': + if (p?.text) { + patchUiState({ status: p.text }) + + if (p.kind && p.kind !== 'status') { + if (lastStatusNoteRef.current !== p.text) { + lastStatusNoteRef.current = p.text + pushActivity( + p.text, + p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' + ) + } + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + patchUiState({ status: getUiState().busy ? 'running…' : 'ready' }) + }, 4000) + } + } + + break + + case 'gateway.stderr': + if (p?.line) { + const line = String(p.line).slice(0, 120) + const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn' + + pushActivity(line, tone) + } + + break + + case 'gateway.start_timeout': + patchUiState({ status: 'gateway startup timeout' }) + pushActivity( + `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`, + 'error' + ) + + break + + case 'gateway.protocol_error': + patchUiState({ status: 'protocol warning' }) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + patchUiState({ status: getUiState().busy ? 'running…' : 'ready' }) + }, 4000) + + if (!protocolWarnedRef.current) { + protocolWarnedRef.current = true + pushActivity('protocol noise detected · /logs to inspect', 'warn') + } + + if (p?.preview) { + pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn') + } + + break + + case 'reasoning.delta': + if (p?.text) { + reasoningRef.current += p.text + scheduleReasoning() + pulseReasoningStreaming() + } + + break + + case 'tool.progress': + if (p?.preview) { + setTools(prev => { + const index = prev.findIndex(tool => tool.name === p.name) + + return index >= 0 + ? [...prev.slice(0, index), { ...prev[index]!, context: p.preview as string }, ...prev.slice(index + 1)] + : prev + }) + } + + break + + case 'tool.generating': + if (p?.name) { + pushTrail(`drafting ${p.name}…`) + } + + break + + case 'tool.start': + pruneTransient() + endReasoningPhase() + setTools(prev => [ + ...prev, + { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } + ]) + + break + case 'tool.complete': { + toolCompleteRibbonRef.current = null + setTools(prev => { + const done = prev.find(tool => tool.id === p.tool_id) + const name = done?.name ?? p.name + const label = toolTrailLabel(name) + + const line = buildToolTrailLine( + name, + done?.context || '', + !!p.error, + (p.error as string) || (p.summary as string) || '' + ) + + const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line] + const remaining = prev.filter(tool => tool.id !== p.tool_id) + + toolCompleteRibbonRef.current = { label, line } + + if (!remaining.length) { + next.push('analyzing tool output…') + } + + turnToolsRef.current = next.slice(-8) + setTurnTrail(turnToolsRef.current) + + return remaining + }) + + if (p?.inline_diff) { + sys(p.inline_diff as string) + } + + break + } + + case 'clarify.request': + patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } }) + patchUiState({ status: 'waiting for input…' }) + + break + + case 'approval.request': + patchOverlayState({ approval: { command: p.command, description: p.description } }) + patchUiState({ status: 'approval needed' }) + + break + + case 'sudo.request': + patchOverlayState({ sudo: { requestId: p.request_id } }) + patchUiState({ status: 'sudo password needed' }) + + break + + case 'secret.request': + patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } }) + patchUiState({ status: 'secret input needed' }) + + break + + case 'background.complete': + patchUiState(state => { + const next = new Set(state.bgTasks) + + next.delete(p.task_id) + + return { ...state, bgTasks: next } + }) + sys(`[bg ${p.task_id}] ${p.text}`) + + break + + case 'btw.complete': + patchUiState(state => { + const next = new Set(state.bgTasks) + + next.delete('btw:x') + + return { ...state, bgTasks: next } + }) + sys(`[btw] ${p.text}`) + + break + + case 'message.delta': + pruneTransient() + endReasoningPhase() + + if (p?.text && !interruptedRef.current) { + bufRef.current = p.rendered ?? bufRef.current + p.text + scheduleStreaming() + } + + break + case 'message.complete': { + const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart() + const persisted = persistedToolLabelsRef.current + const savedReasoning = reasoningRef.current.trim() + + const savedTools = turnToolsRef.current.filter( + line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line)) + ) + + const wasInterrupted = interruptedRef.current + + idle() + clearReasoning() + setStreaming('') + + if (!wasInterrupted) { + appendMessage({ + role: 'assistant', + text: finalText, + thinking: savedReasoning || undefined, + tools: savedTools.length ? savedTools : undefined + }) + + if (bellOnComplete && stdout?.isTTY) { + stdout.write('\x07') + } + } + + turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() + setActivity([]) + bufRef.current = '' + patchUiState({ status: 'ready' }) + + if (p?.usage) { + patchUiState({ usage: p.usage }) + } + + if (queueEditRef.current !== null) { + break + } + + const next = dequeue() + + if (next) { + sendQueued(next) + } + + break + } + + case 'error': + idle() + clearReasoning() + turnToolsRef.current = [] + persistedToolLabelsRef.current.clear() + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + statusTimerRef.current = null + } + + pushActivity(String(p?.message || 'unknown error'), 'error') + sys(`error: ${p?.message}`) + patchUiState({ status: 'ready' }) + + break + } + } +} diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts new file mode 100644 index 0000000000..9f5df4ca92 --- /dev/null +++ b/ui-tui/src/app/createSlashHandler.ts @@ -0,0 +1,1058 @@ +import type { Dispatch, MutableRefObject, SetStateAction } from 'react' + +import { HOTKEYS } from '../constants.js' +import { writeOsc52Clipboard } from '../lib/osc52.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { fmtK } from '../lib/text.js' +import type { DetailsMode, Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js' + +import { imageTokenMeta, introMsg, nextDetailsMode, parseDetailsMode, toTranscriptMessages } from './helpers.js' +import type { GatewayServices } from './interfaces.js' +import { patchOverlayState } from './overlayStore.js' +import { getUiState, patchUiState } from './uiStore.js' + +export interface SlashHandlerContext { + composer: { + enqueue: (text: string) => void + hasSelection: boolean + paste: (quiet?: boolean) => void + queueRef: MutableRefObject + selection: { + copySelection: () => string + } + setInput: Dispatch> + } + gateway: GatewayServices + local: { + catalog: SlashCatalog | null + lastUserMsg: string + maybeWarn: (value: any) => void + messages: Msg[] + } + session: { + closeSession: (targetSid?: string | null) => Promise + die: () => void + guardBusySessionSwitch: (what?: string) => boolean + newSession: (msg?: string) => void + resetVisibleHistory: (info?: SessionInfo | null) => void + resumeById: (id: string) => void + setSessionStartedAt: Dispatch> + } + transcript: { + page: (text: string, title?: string) => void + panel: (title: string, sections: PanelSection[]) => void + send: (text: string) => void + setHistoryItems: Dispatch> + setMessages: Dispatch> + sys: (text: string) => void + trimLastExchange: (items: Msg[]) => Msg[] + } + voice: { + setVoiceEnabled: Dispatch> + } +} + +export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { + const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer + const { gw, rpc } = ctx.gateway + const { catalog, lastUserMsg, maybeWarn, messages } = ctx.local + + const { + closeSession, + die, + guardBusySessionSwitch, + newSession, + resetVisibleHistory, + resumeById, + setSessionStartedAt + } = ctx.session + + const { page, panel, send, setHistoryItems, setMessages, sys, trimLastExchange } = ctx.transcript + const { setVoiceEnabled } = ctx.voice + + const handler = (cmd: string): boolean => { + const ui = getUiState() + const detailsMode = ui.detailsMode + const sid = ui.sid + const [rawName, ...rest] = cmd.slice(1).split(/\s+/) + const name = rawName.toLowerCase() + const arg = rest.join(' ') + + switch (name) { + case 'help': { + const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({ + title: catName, + rows: pairs + })) + + if (catalog?.skillCount) { + sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) + } + + sections.push({ + title: 'TUI', + rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']] + }) + + sections.push({ title: 'Hotkeys', rows: HOTKEYS }) + + panel('Commands', sections) + + return true + } + + case 'quit': + + case 'exit': + + case 'q': + die() + + return true + + case 'clear': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + + patchUiState({ status: 'forging session…' }) + newSession() + + return true + + case 'new': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + + patchUiState({ status: 'forging session…' }) + newSession('new session started') + + return true + + case 'resume': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + + if (arg) { + resumeById(arg) + } else { + patchOverlayState({ picker: true }) + } + + return true + + case 'compact': + if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { + sys('usage: /compact [on|off|toggle]') + + return true + } + + { + const mode = arg.trim().toLowerCase() + const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact + + patchUiState({ compact: next }) + rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) + queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) + } + + return true + + case 'details': + + case 'detail': + if (!arg) { + rpc('config.get', { key: 'details_mode' }) + .then((r: any) => { + const mode = parseDetailsMode(r?.value) ?? detailsMode + patchUiState({ detailsMode: mode }) + sys(`details: ${mode}`) + }) + .catch(() => sys(`details: ${detailsMode}`)) + + return true + } + + { + const mode = arg.trim().toLowerCase() + + if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { + sys('usage: /details [hidden|collapsed|expanded|cycle]') + + return true + } + + const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(detailsMode) : (mode as DetailsMode) + patchUiState({ detailsMode: next }) + rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) + sys(`details: ${next}`) + } + + return true + case 'copy': { + if (!arg && hasSelection) { + const copied = selection.copySelection() + + if (copied) { + sys('copied selection') + + return true + } + } + + const all = messages.filter((m: any) => m.role === 'assistant') + + if (arg && Number.isNaN(parseInt(arg, 10))) { + sys('usage: /copy [number]') + + return true + } + + const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] + + if (!target) { + sys('nothing to copy') + + return true + } + + writeOsc52Clipboard(target.text) + sys('sent OSC52 copy sequence (terminal support required)') + + return true + } + + case 'paste': + if (!arg) { + paste() + + return true + } + + sys('usage: /paste') + + return true + case 'logs': { + const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) + logText ? page(logText, 'Logs') : sys('no gateway logs') + + return true + } + + case 'statusbar': + + case 'sb': + if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { + sys('usage: /statusbar [on|off|toggle]') + + return true + } + + { + const mode = arg.trim().toLowerCase() + const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar + + patchUiState({ statusBar: next }) + rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) + queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) + } + + return true + + case 'queue': + if (!arg) { + sys(`${queueRef.current.length} queued message(s)`) + + return true + } + + enqueue(arg) + sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + + return true + + case 'undo': + if (!sid) { + sys('nothing to undo') + + return true + } + + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (r.removed > 0) { + setMessages((prev: any[]) => trimLastExchange(prev)) + setHistoryItems((prev: any[]) => trimLastExchange(prev)) + sys(`undid ${r.removed} messages`) + } else { + sys('nothing to undo') + } + }) + + return true + + case 'retry': + if (!lastUserMsg) { + sys('nothing to retry') + + return true + } + + if (sid) { + rpc('session.undo', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (r.removed <= 0) { + sys('nothing to retry') + + return + } + + setMessages((prev: any[]) => trimLastExchange(prev)) + setHistoryItems((prev: any[]) => trimLastExchange(prev)) + send(lastUserMsg) + }) + + return true + } + + send(lastUserMsg) + + return true + + case 'background': + + case 'bg': + if (!arg) { + sys('/background ') + + return true + } + + rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => { + if (!r?.task_id) { + return + } + + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id) })) + sys(`bg ${r.task_id} started`) + }) + + return true + + case 'btw': + if (!arg) { + sys('/btw ') + + return true + } + + rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => { + if (!r) { + return + } + + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) + sys('btw running…') + }) + + return true + + case 'model': + if (guardBusySessionSwitch('change models')) { + return true + } + + if (!arg) { + patchOverlayState({ modelPicker: true }) + } else { + rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { + if (!r) { + return + } + + if (!r.value) { + sys('error: invalid response: model switch') + + return + } + + sys(`model → ${r.value}`) + maybeWarn(r) + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: r.value } : { model: r.value, skills: {}, tools: {} } + })) + }) + } + + return true + + case 'image': + rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { + if (!r) { + return + } + + const meta = imageTokenMeta(r) + sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + + if (r?.remainder) { + setInput(r.remainder) + } + }) + + return true + + case 'provider': + gw.request('slash.exec', { command: 'provider', session_id: sid }) + .then((r: any) => { + page( + r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', + 'Provider' + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + case 'skin': + if (arg) { + rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { + if (!r?.value) { + return + } + + sys(`skin → ${r.value}`) + }) + } else { + rpc('config.get', { key: 'skin' }).then((r: any) => { + if (!r) { + return + } + + sys(`skin: ${r.value || 'default'}`) + }) + } + + return true + + case 'yolo': + rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { + if (!r) { + return + } + + sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) + }) + + return true + + case 'reasoning': + if (!arg) { + rpc('config.get', { key: 'reasoning' }).then((r: any) => { + if (!r?.value) { + return + } + + sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + }) + } else { + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { + if (!r?.value) { + return + } + + sys(`reasoning: ${r.value}`) + }) + } + + return true + + case 'verbose': + rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { + if (!r?.value) { + return + } + + sys(`verbose: ${r.value}`) + }) + + return true + + case 'personality': + if (arg) { + rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { + if (!r) { + return + } + + if (r.history_reset) { + resetVisibleHistory(r.info ?? null) + } + + sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) + maybeWarn(r) + }) + } else { + gw.request('slash.exec', { command: 'personality', session_id: sid }) + .then((r: any) => { + panel('Personality', [ + { + text: r?.warning + ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` + : r?.output || '(no output)' + } + ]) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + } + + return true + + case 'compress': + rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { + if (!r) { + return + } + + if (Array.isArray(r.messages)) { + const resumed = toTranscriptMessages(r.messages) + setMessages(resumed) + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + } + + if (r.info) { + patchUiState({ info: r.info }) + } + + if (r.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) + } + + if ((r.removed ?? 0) <= 0) { + sys('nothing to compress') + + return + } + + sys(`compressed ${r.removed} messages${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) + }) + + return true + + case 'stop': + rpc('process.stop', {}).then((r: any) => { + if (!r) { + return + } + + sys(`killed ${r.killed ?? 0} registered process(es)`) + }) + + return true + + case 'branch': + case 'fork': { + const prevSid = sid + rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { + if (r?.session_id) { + void closeSession(prevSid) + patchUiState({ sid: r.session_id }) + setSessionStartedAt(Date.now()) + setHistoryItems([]) + setMessages([]) + sys(`branched → ${r.title}`) + } + }) + + return true + } + + case 'reload-mcp': + + case 'reload_mcp': + rpc('reload.mcp', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + sys('MCP reloaded') + }) + + return true + + case 'title': + rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { + if (!r) { + return + } + + sys(`title: ${r.title || '(none)'}`) + }) + + return true + + case 'usage': + rpc('session.usage', { session_id: sid }).then((r: any) => { + if (r) { + patchUiState({ + usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 } + }) + } + + if (!r?.calls) { + sys('no API calls yet') + + return + } + + const f = (v: number) => (v ?? 0).toLocaleString() + + const cost = + r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + if (cost) { + rows.push(['Cost', cost]) + } + + const sections: PanelSection[] = [{ rows }] + + if (r.context_max) { + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + } + + if (r.compressions) { + sections.push({ text: `Compressions: ${r.compressions}` }) + } + + panel('Usage', sections) + }) + + return true + + case 'save': + rpc('session.save', { session_id: sid }).then((r: any) => { + if (!r?.file) { + return + } + + sys(`saved: ${r.file}`) + }) + + return true + + case 'history': + rpc('session.history', { session_id: sid }).then((r: any) => { + if (typeof r?.count !== 'number') { + return + } + + sys(`${r.count} messages`) + }) + + return true + + case 'profile': + rpc('config.get', { key: 'profile' }).then((r: any) => { + if (!r) { + return + } + + const text = r.display || r.home || '(unknown profile)' + const lines = text.split('\n').filter(Boolean) + + if (lines.length <= 2) { + panel('Profile', [{ text }]) + } else { + page(text, 'Profile') + } + }) + + return true + + case 'voice': + rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { + if (!r) { + return + } + + setVoiceEnabled(!!r?.enabled) + sys(`voice: ${r.enabled ? 'on' : 'off'}`) + }) + + return true + + case 'insights': + rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { + if (!r) { + return + } + + panel('Insights', [ + { + rows: [ + ['Period', `${r.days} days`], + ['Sessions', `${r.sessions}`], + ['Messages', `${r.messages}`] + ] + } + ]) + }) + + return true + case 'rollback': { + const [sub, ...rArgs] = (arg || 'list').split(/\s+/) + + if (!sub || sub === 'list') { + rpc('rollback.list', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (!r.checkpoints?.length) { + return sys('no checkpoints') + } + + panel('Checkpoints', [ + { + rows: r.checkpoints.map( + (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] + ) + } + ]) + }) + } else { + const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub + + const filePath = + sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim() + + rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { + session_id: sid, + hash, + ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) + }).then((r: any) => { + if (!r) { + return + } + + sys(r.rendered || r.diff || r.message || 'done') + }) + } + + return true + } + + case 'browser': { + const [act, ...bArgs] = (arg || 'status').split(/\s+/) + rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => { + if (!r) { + return + } + + sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') + }) + + return true + } + + case 'plugins': + rpc('plugins.list', {}).then((r: any) => { + if (!r) { + return + } + + if (!r.plugins?.length) { + return sys('no plugins') + } + + panel('Plugins', [ + { + items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) + } + ]) + }) + + return true + case 'skills': { + const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean) + + if (!sub || sub === 'list') { + rpc('skills.manage', { action: 'list' }).then((r: any) => { + if (!r) { + return + } + + const sk = r.skills as Record | undefined + + if (!sk || !Object.keys(sk).length) { + return sys('no skills installed') + } + + panel( + 'Installed Skills', + Object.entries(sk).map(([cat, names]) => ({ + title: cat, + items: names as string[] + })) + ) + }) + + return true + } + + if (sub === 'browse') { + const pg = parseInt(sArgs[0] ?? '1', 10) || 1 + rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { + if (!r) { + return + } + + if (!r.items?.length) { + return sys('no skills found in the hub') + } + + const sections: PanelSection[] = [ + { + rows: r.items.map( + (s: any) => + [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [ + string, + string + ] + ) + } + ] + + if (r.page < r.total_pages) { + sections.push({ text: `/skills browse ${r.page + 1} → next page` }) + } + + if (r.page > 1) { + sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) + } + + panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) + }) + + return true + } + + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` + : r?.output || '/skills: no output' + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + case 'agents': + + case 'tasks': + rpc('agents.list', {}) + .then((r: any) => { + if (!r) { + return + } + + const procs = r.processes ?? [] + const running = procs.filter((p: any) => p.status === 'running') + const finished = procs.filter((p: any) => p.status !== 'running') + const sections: PanelSection[] = [] + + if (running.length) { + sections.push({ + title: `Running (${running.length})`, + rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } + + if (finished.length) { + sections.push({ + title: `Finished (${finished.length})`, + rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + } + + if (!sections.length) { + sections.push({ text: 'No active processes' }) + } + + panel('Agents', sections) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + case 'cron': + if (!arg || arg === 'list') { + rpc('cron.manage', { action: 'list' }) + .then((r: any) => { + if (!r) { + return + } + + const jobs = r.jobs ?? [] + + if (!jobs.length) { + return sys('no scheduled jobs') + } + + panel('Cron', [ + { + rows: jobs.map( + (j: any) => + [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string] + ) + } + ]) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + } else { + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => { + sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + } + + return true + + case 'config': + rpc('config.show', {}) + .then((r: any) => { + if (!r) { + return + } + + panel( + 'Config', + (r.sections ?? []).map((s: any) => ({ + title: s.title, + rows: s.rows + })) + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + case 'tools': + rpc('tools.list', { session_id: sid }) + .then((r: any) => { + if (!r) { + return + } + + if (!r.toolsets?.length) { + return sys('no tools') + } + + panel( + 'Tools', + r.toolsets.map((ts: any) => ({ + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, + items: ts.tools + })) + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + case 'toolsets': + rpc('toolsets.list', { session_id: sid }) + .then((r: any) => { + if (!r) { + return + } + + if (!r.toolsets?.length) { + return sys('no toolsets') + } + + panel('Toolsets', [ + { + rows: r.toolsets.map( + (ts: any) => + [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ + string, + string + ] + ) + } + ]) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + default: + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}` + : r?.output || `/${name}: no output` + ) + }) + .catch(() => { + gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) + .then((raw: any) => { + const d = asRpcResult(raw) + + if (!d?.type) { + sys('error: invalid response: command.dispatch') + + return + } + + if (d.type === 'exec') { + sys(d.output || '(no output)') + } else if (d.type === 'alias') { + handler(`/${d.target}${arg ? ' ' + arg : ''}`) + } else if (d.type === 'plugin') { + sys(d.output || '(no output)') + } else if (d.type === 'skill') { + sys(`⚡ loading skill: ${d.name}`) + + if (typeof d.message === 'string' && d.message.trim()) { + send(d.message) + } else { + sys(`/${name}: skill payload missing message`) + } + } + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + + return true + } + } + + return handler +} diff --git a/ui-tui/src/app/gatewayContext.tsx b/ui-tui/src/app/gatewayContext.tsx new file mode 100644 index 0000000000..cdd9347fb0 --- /dev/null +++ b/ui-tui/src/app/gatewayContext.tsx @@ -0,0 +1,24 @@ +import { createContext, type ReactNode, useContext } from 'react' + +import type { GatewayServices } from './interfaces.js' + +const GatewayContext = createContext(null) + +export interface GatewayProviderProps { + children: ReactNode + value: GatewayServices +} + +export function GatewayProvider({ children, value }: GatewayProviderProps) { + return {children} +} + +export function useGateway() { + const value = useContext(GatewayContext) + + if (!value) { + throw new Error('GatewayContext missing') + } + + return value +} diff --git a/ui-tui/src/app/helpers.ts b/ui-tui/src/app/helpers.ts new file mode 100644 index 0000000000..350687d741 --- /dev/null +++ b/ui-tui/src/app/helpers.ts @@ -0,0 +1,167 @@ +import { buildToolTrailLine, fmtK, userDisplay } from '../lib/text.js' +import type { DetailsMode, Msg, SessionInfo } from '../types.js' + +const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] + +export interface PasteSnippet { + label: string + text: string +} + +export const parseDetailsMode = (v: unknown): DetailsMode | null => { + const s = typeof v === 'string' ? v.trim().toLowerCase() : '' + + return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null +} + +export const resolveDetailsMode = (d: any): DetailsMode => + parseDetailsMode(d?.details_mode) ?? + { full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[ + String(d?.thinking_mode ?? '') + .trim() + .toLowerCase() + ] ?? + 'collapsed' + +export const nextDetailsMode = (m: DetailsMode): DetailsMode => + DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! + +export const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) + +export const shortCwd = (cwd: string, max = 28) => { + const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd + + return p.length <= max ? p : `…${p.slice(-(max - 1))}` +} + +export const imageTokenMeta = ( + info: { height?: number; token_estimate?: number; width?: number } | null | undefined +) => { + const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' + + const tok = + typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' + + return [dims, tok].filter(Boolean).join(' · ') +} + +export const looksLikeSlashCommand = (text: string) => { + if (!text.startsWith('/')) { + return false + } + + const first = text.split(/\s+/, 1)[0] || '' + + return !first.slice(1).includes('/') +} + +export const toTranscriptMessages = (rows: unknown): Msg[] => { + if (!Array.isArray(rows)) { + return [] + } + + const result: Msg[] = [] + let pendingTools: string[] = [] + + for (const row of rows) { + if (!row || typeof row !== 'object') { + continue + } + + const role = (row as any).role + const text = (row as any).text + + if (role === 'tool') { + const name = (row as any).name ?? 'tool' + const ctx = (row as any).context ?? '' + pendingTools.push(buildToolTrailLine(name, ctx)) + + continue + } + + if (typeof text !== 'string' || !text.trim()) { + continue + } + + if (role === 'assistant') { + const msg: Msg = { role, text } + + if (pendingTools.length) { + msg.tools = pendingTools + pendingTools = [] + } + + result.push(msg) + + continue + } + + if (role === 'user' || role === 'system') { + pendingTools = [] + result.push({ role, text }) + } + } + + return result +} + +export function fmtDuration(ms: number) { + const total = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(total / 3600) + const mins = Math.floor((total % 3600) / 60) + const secs = total % 60 + + if (hours > 0) { + return `${hours}h ${mins}m` + } + + if (mins > 0) { + return `${mins}m ${secs}s` + } + + return `${secs}s` +} + +export const stickyPromptFromViewport = ( + messages: readonly Msg[], + offsets: ArrayLike, + top: number, + sticky: boolean +) => { + if (sticky || !messages.length) { + return '' + } + + let lo = 0 + let hi = offsets.length + + while (lo < hi) { + const mid = (lo + hi) >> 1 + + if (offsets[mid]! <= top) { + lo = mid + 1 + } else { + hi = mid + } + } + + const first = Math.max(0, Math.min(messages.length - 1, lo - 1)) + + if (messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top) { + return '' + } + + for (let i = first - 1; i >= 0; i--) { + if (messages[i]?.role !== 'user') { + continue + } + + if ((offsets[i] ?? 0) + 1 >= top) { + continue + } + + return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() + } + + return '' +} diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts new file mode 100644 index 0000000000..c4611f9dc4 --- /dev/null +++ b/ui-tui/src/app/interfaces.ts @@ -0,0 +1,67 @@ +import type { GatewayClient } from '../gatewayClient.js' +import type { Theme } from '../theme.js' +import type { ApprovalReq, ClarifyReq, DetailsMode, Msg, SecretReq, SessionInfo, SudoReq, Usage } from '../types.js' + +export interface CompletionItem { + display: string + meta?: string + text: string +} + +export interface GatewayRpc { + (method: string, params?: Record): Promise +} + +export interface GatewayServices { + gw: GatewayClient + rpc: GatewayRpc +} + +export interface OverlayState { + approval: ApprovalReq | null + clarify: ClarifyReq | null + modelPicker: boolean + pager: PagerState | null + picker: boolean + secret: SecretReq | null + sudo: SudoReq | null +} + +export interface PagerState { + lines: string[] + offset: number + title?: string +} + +export interface ToolCompleteRibbon { + label: string + line: string +} + +export interface TranscriptRow { + index: number + key: string + msg: Msg +} + +export interface UiState { + bgTasks: Set + busy: boolean + compact: boolean + detailsMode: DetailsMode + info: SessionInfo | null + sid: string | null + status: string + statusBar: boolean + theme: Theme + usage: Usage +} + +export interface VirtualHistoryState { + bottomSpacer: number + end: number + measureRef: (key: string) => (el: unknown) => void + offsets: ArrayLike + start: number + topSpacer: number +} diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts new file mode 100644 index 0000000000..de4adad62a --- /dev/null +++ b/ui-tui/src/app/overlayStore.ts @@ -0,0 +1,41 @@ +import { atom, computed } from 'nanostores' + +import type { OverlayState } from './interfaces.js' + +function buildOverlayState(): OverlayState { + return { + approval: null, + clarify: null, + modelPicker: false, + pager: null, + picker: false, + secret: null, + sudo: null + } +} + +export const $overlayState = atom(buildOverlayState()) + +export const $isBlocked = computed($overlayState, state => + Boolean( + state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo + ) +) + +export function getOverlayState() { + return $overlayState.get() +} + +export function patchOverlayState(next: Partial | ((state: OverlayState) => OverlayState)) { + if (typeof next === 'function') { + $overlayState.set(next($overlayState.get())) + + return + } + + $overlayState.set({ ...$overlayState.get(), ...next }) +} + +export function resetOverlayState() { + $overlayState.set(buildOverlayState()) +} diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts new file mode 100644 index 0000000000..501db36c92 --- /dev/null +++ b/ui-tui/src/app/uiStore.ts @@ -0,0 +1,41 @@ +import { atom } from 'nanostores' + +import { ZERO } from '../constants.js' +import { DEFAULT_THEME } from '../theme.js' + +import type { UiState } from './interfaces.js' + +function buildUiState(): UiState { + return { + bgTasks: new Set(), + busy: false, + compact: false, + detailsMode: 'collapsed', + info: null, + sid: null, + status: 'summoning hermes…', + statusBar: true, + theme: DEFAULT_THEME, + usage: ZERO + } +} + +export const $uiState = atom(buildUiState()) + +export function getUiState() { + return $uiState.get() +} + +export function patchUiState(next: Partial | ((state: UiState) => UiState)) { + if (typeof next === 'function') { + $uiState.set(next($uiState.get())) + + return + } + + $uiState.set({ ...$uiState.get(), ...next }) +} + +export function resetUiState() { + $uiState.set(buildUiState()) +} diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts new file mode 100644 index 0000000000..7e8b317534 --- /dev/null +++ b/ui-tui/src/app/useComposerState.ts @@ -0,0 +1,199 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { useStore } from '@nanostores/react' +import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useState } from 'react' + +import type { PasteEvent } from '../components/textInput.js' +import type { GatewayClient } from '../gatewayClient.js' +import { useCompletion } from '../hooks/useCompletion.js' +import { useInputHistory } from '../hooks/useInputHistory.js' +import { useQueue } from '../hooks/useQueue.js' +import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' + +import { LARGE_PASTE } from './constants.js' +import type { PasteSnippet } from './helpers.js' +import type { CompletionItem } from './interfaces.js' +import { $isBlocked } from './overlayStore.js' + +export interface ComposerPasteResult { + cursor: number + value: string +} + +export interface ComposerActions { + clearIn: () => void + dequeue: () => string | undefined + enqueue: (text: string) => void + handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null + openEditor: () => void + pushHistory: (text: string) => void + replaceQueue: (index: number, text: string) => void + setCompIdx: Dispatch> + setHistoryIdx: Dispatch> + setInput: Dispatch> + setInputBuf: Dispatch> + setPasteSnips: Dispatch> + setQueueEdit: (index: number | null) => void + syncQueue: () => void +} + +export interface ComposerRefs { + historyDraftRef: MutableRefObject + historyRef: MutableRefObject + queueEditRef: MutableRefObject + queueRef: MutableRefObject + submitRef: MutableRefObject<(value: string) => void> +} + +export interface ComposerState { + compIdx: number + compReplace: number + completions: CompletionItem[] + historyIdx: number | null + input: string + inputBuf: string[] + pasteSnips: PasteSnippet[] + queueEditIdx: number | null + queuedDisplay: string[] +} + +export interface UseComposerStateOptions { + gw: GatewayClient + onClipboardPaste: (quiet?: boolean) => Promise | void + submitRef: MutableRefObject<(value: string) => void> +} + +export interface UseComposerStateResult { + actions: ComposerActions + refs: ComposerRefs + state: ComposerState +} + +export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult { + const [input, setInput] = useState('') + const [inputBuf, setInputBuf] = useState([]) + const [pasteSnips, setPasteSnips] = useState([]) + const isBlocked = useStore($isBlocked) + + const { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } = + useQueue() + + const { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } = useInputHistory() + const { completions, compIdx, setCompIdx, compReplace } = useCompletion(input, isBlocked, gw) + + const clearIn = useCallback(() => { + setInput('') + setInputBuf([]) + setQueueEdit(null) + setHistoryIdx(null) + historyDraftRef.current = '' + }, [historyDraftRef, setQueueEdit, setHistoryIdx]) + + const handleTextPaste = useCallback( + ({ bracketed, cursor, hotkey, text, value }: PasteEvent) => { + if (hotkey) { + void onClipboardPaste(false) + + return null + } + + const cleanedText = stripTrailingPasteNewlines(text) + + if (!cleanedText || !/[^\n]/.test(cleanedText)) { + if (bracketed) { + void onClipboardPaste(true) + } + + return null + } + + const lineCount = cleanedText.split('\n').length + + if (cleanedText.length < LARGE_PASTE.chars && lineCount < LARGE_PASTE.lines) { + return { + cursor: cursor + cleanedText.length, + value: value.slice(0, cursor) + cleanedText + value.slice(cursor) + } + } + + const label = pasteTokenLabel(cleanedText, lineCount) + const lead = cursor > 0 && !/\s/.test(value[cursor - 1] ?? '') ? ' ' : '' + const tail = cursor < value.length && !/\s/.test(value[cursor] ?? '') ? ' ' : '' + const insert = `${lead}${label}${tail}` + + setPasteSnips(prev => [...prev, { label, text: cleanedText }].slice(-32)) + + return { + cursor: cursor + insert.length, + value: value.slice(0, cursor) + insert + value.slice(cursor) + } + }, + [onClipboardPaste] + ) + + const openEditor = useCallback(() => { + const editor = process.env.EDITOR || process.env.VISUAL || 'vi' + const file = join(mkdtempSync(join(tmpdir(), 'hermes-')), 'prompt.md') + + writeFileSync(file, [...inputBuf, input].join('\n')) + process.stdout.write('\x1b[?1049l') + const { status: code } = spawnSync(editor, [file], { stdio: 'inherit' }) + process.stdout.write('\x1b[?1049h\x1b[2J\x1b[H') + + if (code === 0) { + const text = readFileSync(file, 'utf8').trimEnd() + + if (text) { + setInput('') + setInputBuf([]) + submitRef.current(text) + } + } + + try { + unlinkSync(file) + } catch { + /* noop */ + } + }, [input, inputBuf, submitRef]) + + return { + actions: { + clearIn, + dequeue, + enqueue, + handleTextPaste, + openEditor, + pushHistory, + replaceQueue: replaceQ, + setCompIdx, + setHistoryIdx, + setInput, + setInputBuf, + setPasteSnips, + setQueueEdit, + syncQueue + }, + refs: { + historyDraftRef, + historyRef, + queueEditRef, + queueRef, + submitRef + }, + state: { + compIdx, + compReplace, + completions, + historyIdx, + input, + inputBuf, + pasteSnips, + queueEditIdx, + queuedDisplay + } + } +} diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts new file mode 100644 index 0000000000..1db4594b94 --- /dev/null +++ b/ui-tui/src/app/useInputHandlers.ts @@ -0,0 +1,345 @@ +import { type ScrollBoxHandle, useInput } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import type { Dispatch, RefObject, SetStateAction } from 'react' + +import type { Msg } from '../types.js' + +import type { GatewayServices } from './interfaces.js' +import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { getUiState, patchUiState } from './uiStore.js' +import type { ComposerActions, ComposerRefs, ComposerState } from './useComposerState.js' +import type { TurnActions, TurnRefs } from './useTurnState.js' + +export interface InputHandlerActions { + answerClarify: (answer: string) => void + appendMessage: (msg: Msg) => void + die: () => void + dispatchSubmission: (full: string) => void + guardBusySessionSwitch: (what?: string) => boolean + newSession: (msg?: string) => void + sys: (text: string) => void +} + +export interface InputHandlerContext { + actions: InputHandlerActions + composer: { + actions: ComposerActions + refs: ComposerRefs + state: ComposerState + } + gateway: GatewayServices + terminal: { + hasSelection: boolean + scrollRef: RefObject + scrollWithSelection: (delta: number) => void + selection: { + copySelection: () => string + } + stdout?: NodeJS.WriteStream + } + turn: { + actions: TurnActions + refs: TurnRefs + } + voice: { + recording: boolean + setProcessing: Dispatch> + setRecording: Dispatch> + } + wheelStep: number +} + +export interface InputHandlerResult { + pagerPageSize: number +} + +export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { + const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx + const overlay = useStore($overlayState) + const isBlocked = useStore($isBlocked) + const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) + + const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target + + const copySelection = () => { + if (terminal.selection.copySelection()) { + actions.sys('copied selection') + } + } + + useInput((ch, key) => { + const live = getUiState() + + if (isBlocked) { + if (overlay.pager) { + if (key.return || ch === ' ') { + const next = overlay.pager.offset + pagerPageSize + + patchOverlayState({ + pager: next >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: next } + }) + } else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') { + patchOverlayState({ pager: null }) + } + + return + } + + if (ctrl(key, ch, 'c')) { + if (overlay.clarify) { + actions.answerClarify('') + } else if (overlay.approval) { + gateway.rpc('approval.respond', { choice: 'deny', session_id: live.sid }).then(r => { + if (!r) { + return + } + + patchOverlayState({ approval: null }) + actions.sys('denied') + }) + } else if (overlay.sudo) { + gateway.rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }).then(r => { + if (!r) { + return + } + + patchOverlayState({ sudo: null }) + actions.sys('sudo cancelled') + }) + } else if (overlay.secret) { + gateway.rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }).then(r => { + if (!r) { + return + } + + patchOverlayState({ secret: null }) + actions.sys('secret entry cancelled') + }) + } else if (overlay.modelPicker) { + patchOverlayState({ modelPicker: false }) + } else if (overlay.picker) { + patchOverlayState({ picker: false }) + } + } else if (key.escape && overlay.picker) { + patchOverlayState({ picker: false }) + } + + return + } + + if ( + composer.state.completions.length && + composer.state.input && + composer.state.historyIdx === null && + (key.upArrow || key.downArrow) + ) { + composer.actions.setCompIdx(index => + key.upArrow + ? (index - 1 + composer.state.completions.length) % composer.state.completions.length + : (index + 1) % composer.state.completions.length + ) + + return + } + + if (key.wheelUp) { + terminal.scrollWithSelection(-wheelStep) + + return + } + + if (key.wheelDown) { + terminal.scrollWithSelection(wheelStep) + + return + } + + if (key.shift && key.upArrow) { + terminal.scrollWithSelection(-1) + + return + } + + if (key.shift && key.downArrow) { + terminal.scrollWithSelection(1) + + return + } + + if (key.pageUp || key.pageDown) { + const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8) + const step = Math.max(4, viewport - 2) + + terminal.scrollWithSelection(key.pageUp ? -step : step) + + return + } + + if (key.ctrl && key.shift && ch.toLowerCase() === 'c') { + copySelection() + + return + } + + if (key.upArrow && !composer.state.inputBuf.length) { + if (composer.refs.queueRef.current.length) { + const index = + composer.state.queueEditIdx === null + ? 0 + : (composer.state.queueEditIdx + 1) % composer.refs.queueRef.current.length + + composer.actions.setQueueEdit(index) + composer.actions.setHistoryIdx(null) + composer.actions.setInput(composer.refs.queueRef.current[index] ?? '') + } else if (composer.refs.historyRef.current.length) { + const index = + composer.state.historyIdx === null + ? composer.refs.historyRef.current.length - 1 + : Math.max(0, composer.state.historyIdx - 1) + + if (composer.state.historyIdx === null) { + composer.refs.historyDraftRef.current = composer.state.input + } + + composer.actions.setHistoryIdx(index) + composer.actions.setQueueEdit(null) + composer.actions.setInput(composer.refs.historyRef.current[index] ?? '') + } + + return + } + + if (key.downArrow && !composer.state.inputBuf.length) { + if (composer.refs.queueRef.current.length) { + const index = + composer.state.queueEditIdx === null + ? composer.refs.queueRef.current.length - 1 + : (composer.state.queueEditIdx - 1 + composer.refs.queueRef.current.length) % + composer.refs.queueRef.current.length + + composer.actions.setQueueEdit(index) + composer.actions.setHistoryIdx(null) + composer.actions.setInput(composer.refs.queueRef.current[index] ?? '') + } else if (composer.state.historyIdx !== null) { + const next = composer.state.historyIdx + 1 + + if (next >= composer.refs.historyRef.current.length) { + composer.actions.setHistoryIdx(null) + composer.actions.setInput(composer.refs.historyDraftRef.current) + } else { + composer.actions.setHistoryIdx(next) + composer.actions.setInput(composer.refs.historyRef.current[next] ?? '') + } + } + + return + } + + if (ctrl(key, ch, 'c')) { + if (terminal.hasSelection) { + copySelection() + } else if (live.busy && live.sid) { + turn.actions.interruptTurn({ + appendMessage: actions.appendMessage, + gw: gateway.gw, + sid: live.sid, + sys: actions.sys + }) + } else if (composer.state.input || composer.state.inputBuf.length) { + composer.actions.clearIn() + } else { + return actions.die() + } + + return + } + + if (ctrl(key, ch, 'd')) { + return actions.die() + } + + if (ctrl(key, ch, 'l')) { + if (actions.guardBusySessionSwitch()) { + return + } + + patchUiState({ status: 'forging session…' }) + actions.newSession() + + return + } + + if (ctrl(key, ch, 'b')) { + if (voice.recording) { + voice.setRecording(false) + voice.setProcessing(true) + gateway + .rpc('voice.record', { action: 'stop' }) + .then((r: any) => { + if (!r) { + return + } + + const transcript = String(r?.text || '').trim() + + if (transcript) { + composer.actions.setInput(prev => + prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript + ) + } else { + actions.sys('voice: no speech detected') + } + }) + .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) + .finally(() => { + voice.setProcessing(false) + patchUiState({ status: 'ready' }) + }) + } else { + gateway + .rpc('voice.record', { action: 'start' }) + .then((r: any) => { + if (!r) { + return + } + + voice.setRecording(true) + patchUiState({ status: 'recording…' }) + }) + .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) + } + + return + } + + if (ctrl(key, ch, 'g')) { + return composer.actions.openEditor() + } + + if (key.tab && composer.state.completions.length) { + const row = composer.state.completions[composer.state.compIdx] + + if (row?.text) { + const text = + composer.state.input.startsWith('/') && row.text.startsWith('/') && composer.state.compReplace > 0 + ? row.text.slice(1) + : row.text + + composer.actions.setInput(composer.state.input.slice(0, composer.state.compReplace) + text) + } + + return + } + + if (ctrl(key, ch, 'k') && composer.refs.queueRef.current.length && live.sid) { + const next = composer.actions.dequeue() + + if (next) { + composer.actions.setQueueEdit(null) + actions.dispatchSubmission(next) + } + } + }) + + return { pagerPageSize } +} diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts new file mode 100644 index 0000000000..a6a611bc60 --- /dev/null +++ b/ui-tui/src/app/useTurnState.ts @@ -0,0 +1,296 @@ +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState +} from 'react' + +import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' +import type { ActiveTool, ActivityItem, Msg } from '../types.js' + +import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js' +import type { ToolCompleteRibbon } from './interfaces.js' +import { resetOverlayState } from './overlayStore.js' +import { patchUiState } from './uiStore.js' + +export interface InterruptTurnOptions { + appendMessage: (msg: Msg) => void + gw: { request: (method: string, params?: Record) => Promise } + sid: string + sys: (text: string) => void +} + +export interface TurnActions { + clearReasoning: () => void + endReasoningPhase: () => void + idle: () => void + interruptTurn: (options: InterruptTurnOptions) => void + pruneTransient: () => void + pulseReasoningStreaming: () => void + pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void + pushTrail: (line: string) => void + scheduleReasoning: () => void + scheduleStreaming: () => void + setActivity: Dispatch> + setReasoning: Dispatch> + setReasoningActive: Dispatch> + setReasoningStreaming: Dispatch> + setStreaming: Dispatch> + setTools: Dispatch> + setTurnTrail: Dispatch> +} + +export interface TurnRefs { + bufRef: MutableRefObject + interruptedRef: MutableRefObject + lastStatusNoteRef: MutableRefObject + persistedToolLabelsRef: MutableRefObject> + protocolWarnedRef: MutableRefObject + reasoningRef: MutableRefObject + reasoningStreamingTimerRef: MutableRefObject | null> + reasoningTimerRef: MutableRefObject | null> + statusTimerRef: MutableRefObject | null> + streamTimerRef: MutableRefObject | null> + toolCompleteRibbonRef: MutableRefObject + turnToolsRef: MutableRefObject +} + +export interface TurnState { + activity: ActivityItem[] + reasoning: string + reasoningActive: boolean + reasoningStreaming: boolean + streaming: string + tools: ActiveTool[] + turnTrail: string[] +} + +export interface UseTurnStateResult { + actions: TurnActions + refs: TurnRefs + state: TurnState +} + +export function useTurnState(): UseTurnStateResult { + const [activity, setActivity] = useState([]) + const [reasoning, setReasoning] = useState('') + const [reasoningActive, setReasoningActive] = useState(false) + const [reasoningStreaming, setReasoningStreaming] = useState(false) + const [streaming, setStreaming] = useState('') + const [tools, setTools] = useState([]) + const [turnTrail, setTurnTrail] = useState([]) + + const activityIdRef = useRef(0) + const bufRef = useRef('') + const interruptedRef = useRef(false) + const lastStatusNoteRef = useRef('') + const persistedToolLabelsRef = useRef>(new Set()) + const protocolWarnedRef = useRef(false) + const reasoningRef = useRef('') + const reasoningStreamingTimerRef = useRef | null>(null) + const reasoningTimerRef = useRef | null>(null) + const statusTimerRef = useRef | null>(null) + const streamTimerRef = useRef | null>(null) + const toolCompleteRibbonRef = useRef(null) + const turnToolsRef = useRef([]) + + const setTrail = (next: string[]) => { + turnToolsRef.current = next + + return next + } + + const pulseReasoningStreaming = useCallback(() => { + if (reasoningStreamingTimerRef.current) { + clearTimeout(reasoningStreamingTimerRef.current) + } + + setReasoningActive(true) + setReasoningStreaming(true) + reasoningStreamingTimerRef.current = setTimeout(() => { + reasoningStreamingTimerRef.current = null + setReasoningStreaming(false) + }, REASONING_PULSE_MS) + }, []) + + const scheduleStreaming = useCallback(() => { + if (streamTimerRef.current) { + return + } + + streamTimerRef.current = setTimeout(() => { + streamTimerRef.current = null + setStreaming(bufRef.current.trimStart()) + }, STREAM_BATCH_MS) + }, []) + + const scheduleReasoning = useCallback(() => { + if (reasoningTimerRef.current) { + return + } + + reasoningTimerRef.current = setTimeout(() => { + reasoningTimerRef.current = null + setReasoning(reasoningRef.current) + }, STREAM_BATCH_MS) + }, []) + + const endReasoningPhase = useCallback(() => { + if (reasoningStreamingTimerRef.current) { + clearTimeout(reasoningStreamingTimerRef.current) + reasoningStreamingTimerRef.current = null + } + + setReasoningStreaming(false) + setReasoningActive(false) + }, []) + + useEffect( + () => () => { + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current) + } + + if (reasoningTimerRef.current) { + clearTimeout(reasoningTimerRef.current) + } + + if (reasoningStreamingTimerRef.current) { + clearTimeout(reasoningStreamingTimerRef.current) + } + }, + [] + ) + + const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { + setActivity(prev => { + const base = replaceLabel ? prev.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) : prev + + if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) { + return base + } + + activityIdRef.current++ + + return [...base, { id: activityIdRef.current, text, tone }].slice(-8) + }) + }, []) + + const pruneTransient = useCallback(() => { + setTurnTrail(prev => { + const next = prev.filter(line => !isTransientTrailLine(line)) + + return next.length === prev.length ? prev : setTrail(next) + }) + }, []) + + const pushTrail = useCallback((line: string) => { + setTurnTrail(prev => + prev.at(-1) === line ? prev : setTrail([...prev.filter(item => !isTransientTrailLine(item)), line].slice(-8)) + ) + }, []) + + const clearReasoning = useCallback(() => { + if (reasoningTimerRef.current) { + clearTimeout(reasoningTimerRef.current) + reasoningTimerRef.current = null + } + + reasoningRef.current = '' + setReasoning('') + }, []) + + const idle = useCallback(() => { + endReasoningPhase() + setTools([]) + setTurnTrail([]) + patchUiState({ busy: false }) + resetOverlayState() + + if (streamTimerRef.current) { + clearTimeout(streamTimerRef.current) + streamTimerRef.current = null + } + + setStreaming('') + bufRef.current = '' + }, [endReasoningPhase]) + + const interruptTurn = useCallback( + ({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => { + interruptedRef.current = true + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + const partial = (streaming || bufRef.current).trimStart() + + if (partial) { + appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) + } else { + sys('interrupted') + } + + idle() + clearReasoning() + setActivity([]) + turnToolsRef.current = [] + patchUiState({ status: 'interrupted' }) + + if (statusTimerRef.current) { + clearTimeout(statusTimerRef.current) + } + + statusTimerRef.current = setTimeout(() => { + statusTimerRef.current = null + patchUiState({ status: 'ready' }) + }, 1500) + }, + [clearReasoning, idle, streaming] + ) + + return { + actions: { + clearReasoning, + endReasoningPhase, + idle, + interruptTurn, + pruneTransient, + pulseReasoningStreaming, + pushActivity, + pushTrail, + scheduleReasoning, + scheduleStreaming, + setActivity, + setReasoning, + setReasoningActive, + setReasoningStreaming, + setStreaming, + setTools, + setTurnTrail + }, + refs: { + bufRef, + interruptedRef, + lastStatusNoteRef, + persistedToolLabelsRef, + protocolWarnedRef, + reasoningRef, + reasoningStreamingTimerRef, + reasoningTimerRef, + statusTimerRef, + streamTimerRef, + toolCompleteRibbonRef, + turnToolsRef + }, + state: { + activity, + reasoning, + reasoningActive, + reasoningStreaming, + streaming, + tools, + turnTrail + } + } +} diff --git a/ui-tui/src/components/activityLane.tsx b/ui-tui/src/components/activityLane.tsx deleted file mode 100644 index 053c62c59a..0000000000 --- a/ui-tui/src/components/activityLane.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { Box, Text } from '@hermes/ink' - -import type { Theme } from '../theme.js' -import type { ActivityItem } from '../types.js' - -const toneColor = (item: ActivityItem, t: Theme) => - item.tone === 'error' ? t.color.error : item.tone === 'warn' ? t.color.warn : t.color.dim - -export function ActivityLane({ items, t }: { items: ActivityItem[]; t: Theme }) { - if (!items.length) { - return null - } - - return ( - - {items.slice(-4).map(item => ( - - {t.brand.tool} {item.text} - - ))} - - ) -} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx new file mode 100644 index 0000000000..bb5769f3a9 --- /dev/null +++ b/ui-tui/src/components/appChrome.tsx @@ -0,0 +1,227 @@ +import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' +import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react' + +import { stickyPromptFromViewport } from '../app/helpers.js' +import { fmtK } from '../lib/text.js' +import type { Theme } from '../theme.js' +import type { Msg, Usage } from '../types.js' + +function ctxBarColor(pct: number | undefined, t: Theme) { + if (pct == null) { + return t.color.dim + } + + if (pct >= 95) { + return t.color.statusCritical + } + + if (pct > 80) { + return t.color.statusBad + } + + if (pct >= 50) { + return t.color.statusWarn + } + + return t.color.statusGood +} + +function ctxBar(pct: number | undefined, w = 10) { + const p = Math.max(0, Math.min(100, pct ?? 0)) + const filled = Math.round((p / 100) * w) + + return '█'.repeat(filled) + '░'.repeat(w - filled) +} + +export function StatusRule({ + cwdLabel, + cols, + status, + statusColor, + model, + usage, + bgCount, + durationLabel, + voiceLabel, + t +}: { + cwdLabel: string + cols: number + status: string + statusColor: string + model: string + usage: Usage + bgCount: number + durationLabel?: string + voiceLabel?: string + t: Theme +}) { + const pct = usage.context_percent + const barColor = ctxBarColor(pct, t) + + const ctxLabel = usage.context_max + ? `${fmtK(usage.context_used ?? 0)}/${fmtK(usage.context_max)}` + : usage.total > 0 + ? `${fmtK(usage.total)} tok` + : '' + + const pctLabel = pct != null ? `${pct}%` : '' + const bar = usage.context_max ? ctxBar(pct) : '' + const leftWidth = Math.max(12, cols - cwdLabel.length - 3) + + return ( + + + + {'─ '} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} + {bar ? ( + + {' │ '} + [{bar}] {pctLabel} + + ) : null} + {durationLabel ? │ {durationLabel} : null} + {voiceLabel ? │ {voiceLabel} : null} + {bgCount > 0 ? │ {bgCount} bg : null} + + + + {cwdLabel} + + ) +} + +export function FloatBox({ children, color }: { children: ReactNode; color: string }) { + return ( + + {children} + + ) +} + +export function StickyPromptTracker({ + messages, + offsets, + scrollRef, + onChange +}: { + messages: readonly Msg[] + offsets: ArrayLike + scrollRef: RefObject + onChange: (text: string) => void +}) { + useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => { + const s = scrollRef.current + + if (!s) { + return NaN + } + + const top = Math.max(0, s.getScrollTop() + s.getPendingDelta()) + + return s.isSticky() ? -1 - top : top + }, + () => NaN + ) + + const s = scrollRef.current + const top = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const text = stickyPromptFromViewport(messages, offsets, top, s?.isSticky() ?? true) + + useEffect(() => onChange(text), [onChange, text]) + + return null +} + +export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) { + useSyncExternalStore( + useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + () => { + const s = scrollRef.current + + if (!s) { + return NaN + } + + return `${s.getScrollTop() + s.getPendingDelta()}:${s.getViewportHeight()}:${s.getScrollHeight()}` + }, + () => '' + ) + + const [hover, setHover] = useState(false) + const [grab, setGrab] = useState(null) + + const s = scrollRef.current + const vp = Math.max(0, s?.getViewportHeight() ?? 0) + + if (!vp) { + return + } + + const total = Math.max(vp, s?.getScrollHeight() ?? vp) + const scrollable = total > vp + const thumb = scrollable ? Math.max(1, Math.round((vp * vp) / total)) : vp + const travel = Math.max(1, vp - thumb) + const pos = Math.max(0, (s?.getScrollTop() ?? 0) + (s?.getPendingDelta() ?? 0)) + const thumbTop = scrollable ? Math.round((pos / Math.max(1, total - vp)) * travel) : 0 + + const jump = (row: number, offset: number) => { + if (!s || !scrollable) { + return + } + + s.scrollTo(Math.round((Math.max(0, Math.min(travel, row - offset)) / travel) * Math.max(0, total - vp))) + } + + return ( + { + const row = Math.max(0, Math.min(vp - 1, e.localRow ?? 0)) + const off = row >= thumbTop && row < thumbTop + thumb ? row - thumbTop : Math.floor(thumb / 2) + setGrab(off) + jump(row, off) + }} + onMouseDrag={(e: { localRow?: number }) => + jump(Math.max(0, Math.min(vp - 1, e.localRow ?? 0)), grab ?? Math.floor(thumb / 2)) + } + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + onMouseUp={() => setGrab(null)} + width={1} + > + {Array.from({ length: vp }, (_, i) => { + const active = i >= thumbTop && i < thumbTop + thumb + + const color = active + ? grab !== null + ? t.color.gold + : hover + ? t.color.amber + : t.color.bronze + : hover + ? t.color.bronze + : t.color.dim + + return ( + + {scrollable ? (active ? '┃' : '│') : ' '} + + ) + })} + + ) +} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx new file mode 100644 index 0000000000..be33502ee9 --- /dev/null +++ b/ui-tui/src/components/appLayout.tsx @@ -0,0 +1,248 @@ +import { AlternateScreen, Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import type { RefObject } from 'react' + +import { PLACEHOLDER } from '../app/constants.js' +import type { CompletionItem, TranscriptRow, VirtualHistoryState } from '../app/interfaces.js' +import { $isBlocked } from '../app/overlayStore.js' +import { $uiState } from '../app/uiStore.js' +import type { ActiveTool, ActivityItem, Msg } from '../types.js' + +import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' +import { AppOverlays } from './appOverlays.js' +import { Banner, Panel, SessionPanel } from './branding.js' +import { MessageLine } from './messageLine.js' +import { QueuedMessages } from './queuedMessages.js' +import type { PasteEvent } from './textInput.js' +import { TextInput } from './textInput.js' +import { ToolTrail } from './thinking.js' + +export interface AppLayoutActions { + answerApproval: (choice: string) => void + answerClarify: (answer: string) => void + answerSecret: (value: string) => void + answerSudo: (pw: string) => void + onModelSelect: (value: string) => void + resumeById: (id: string) => void + setStickyPrompt: (value: string) => void +} + +export interface AppLayoutComposerProps { + cols: number + compIdx: number + completions: CompletionItem[] + empty: boolean + handleTextPaste: (event: PasteEvent) => { cursor: number; value: string } | null + input: string + inputBuf: string[] + pagerPageSize: number + queueEditIdx: number | null + queuedDisplay: string[] + submit: (value: string) => void + updateInput: (next: string) => void +} + +export interface AppLayoutProgressProps { + activity: ActivityItem[] + reasoning: string + reasoningActive: boolean + reasoningStreaming: boolean + showProgressArea: boolean + showStreamingArea: boolean + streaming: string + tools: ActiveTool[] + turnTrail: string[] +} + +export interface AppLayoutStatusProps { + cwdLabel: string + durationLabel: string + showStickyPrompt: boolean + statusColor: string + stickyPrompt: string + voiceLabel: string +} + +export interface AppLayoutTranscriptProps { + historyItems: Msg[] + scrollRef: RefObject + virtualHistory: VirtualHistoryState + virtualRows: TranscriptRow[] +} + +export interface AppLayoutProps { + actions: AppLayoutActions + composer: AppLayoutComposerProps + mouseTracking: boolean + progress: AppLayoutProgressProps + status: AppLayoutStatusProps + transcript: AppLayoutTranscriptProps +} + +export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) { + const ui = useStore($uiState) + const isBlocked = useStore($isBlocked) + const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end) + + return ( + + + + + + {transcript.virtualHistory.topSpacer > 0 ? : null} + + {visibleHistory.map(row => ( + + {row.msg.kind === 'intro' && row.msg.info ? ( + + + + + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( + + ) : ( + + )} + + ))} + + {transcript.virtualHistory.bottomSpacer > 0 ? ( + + ) : null} + + {progress.showProgressArea && ( + + )} + + {progress.showStreamingArea && ( + + )} + + + + + + + + + + + + + + {ui.bgTasks.size > 0 && ( + + {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running + + )} + + {status.showStickyPrompt ? ( + + + {status.stickyPrompt} + + ) : ( + + )} + + + {ui.statusBar && ( + + )} + + + + + {!isBlocked && ( + + {composer.inputBuf.map((line, i) => ( + + + {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} + + + {line || ' '} + + ))} + + + + + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} + + + + + + + )} + + {!composer.empty && !ui.sid && ⚕ {ui.status}} + + + + ) +} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx new file mode 100644 index 0000000000..e3b646edde --- /dev/null +++ b/ui-tui/src/components/appOverlays.tsx @@ -0,0 +1,175 @@ +import { Box, Text } from '@hermes/ink' +import { useStore } from '@nanostores/react' + +import { useGateway } from '../app/gatewayContext.js' +import type { CompletionItem } from '../app/interfaces.js' +import { $overlayState, patchOverlayState } from '../app/overlayStore.js' +import { $uiState } from '../app/uiStore.js' + +import { FloatBox } from './appChrome.js' +import { MaskedPrompt } from './maskedPrompt.js' +import { ModelPicker } from './modelPicker.js' +import { ApprovalPrompt, ClarifyPrompt } from './prompts.js' +import { SessionPicker } from './sessionPicker.js' + +export interface AppOverlaysProps { + cols: number + compIdx: number + completions: CompletionItem[] + onApprovalChoice: (choice: string) => void + onClarifyAnswer: (value: string) => void + onModelSelect: (value: string) => void + onPickerSelect: (sessionId: string) => void + onSecretSubmit: (value: string) => void + onSudoSubmit: (pw: string) => void + pagerPageSize: number +} + +export function AppOverlays({ + cols, + compIdx, + completions, + onApprovalChoice, + onClarifyAnswer, + onModelSelect, + onPickerSelect, + onSecretSubmit, + onSudoSubmit, + pagerPageSize +}: AppOverlaysProps) { + const { gw } = useGateway() + const overlay = useStore($overlayState) + const ui = useStore($uiState) + + if ( + !( + overlay.approval || + overlay.clarify || + overlay.modelPicker || + overlay.pager || + overlay.picker || + overlay.secret || + overlay.sudo || + completions.length + ) + ) { + return null + } + + const start = Math.max(0, compIdx - 8) + + return ( + + {overlay.clarify && ( + + onClarifyAnswer('')} + req={overlay.clarify} + t={ui.theme} + /> + + )} + + {overlay.approval && ( + + + + )} + + {overlay.sudo && ( + + + + )} + + {overlay.secret && ( + + + + )} + + {overlay.picker && ( + + patchOverlayState({ picker: false })} + onSelect={onPickerSelect} + t={ui.theme} + /> + + )} + + {overlay.modelPicker && ( + + patchOverlayState({ modelPicker: false })} + onSelect={onModelSelect} + sessionId={ui.sid} + t={ui.theme} + /> + + )} + + {overlay.pager && ( + + + {overlay.pager.title && ( + + + {overlay.pager.title} + + + )} + + {overlay.pager.lines.slice(overlay.pager.offset, overlay.pager.offset + pagerPageSize).map((line, i) => ( + {line} + ))} + + + + {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length + ? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` + : `end · q to close (${overlay.pager.lines.length} lines)`} + + + + + )} + + {!!completions.length && ( + + + {completions.slice(start, compIdx + 8).map((item, i) => { + const active = start + i === compIdx + + return ( + + + {' '} + {item.display} + + {item.meta ? {item.meta} : null} + + ) + })} + + + )} + + ) +} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index b4c8e11adf..dbcfeb6071 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -44,7 +44,7 @@ export const MessageLine = memo(function MessageLine({ } const { body, glyph, prefix } = ROLE[msg.role](t) - const thinking = msg.thinking?.replace(/\n/g, ' ').trim() ?? '' + const thinking = msg.thinking?.trim() ?? '' const showDetails = detailsMode !== 'hidden' && (Boolean(msg.tools?.length) || Boolean(thinking)) const content = (() => { diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index c7ae29e24d..7688e6148c 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -14,16 +14,6 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) { return { end, showLead: start > 0, showTail: end < queueLen, start } } -export function estimateQueuedRows(queueLen: number, queueEditIdx: number | null): number { - if (!queueLen) { - return 0 - } - - const win = getQueueWindow(queueLen, queueEditIdx) - - return 1 + 1 + (win.showLead ? 1 : 0) + (win.end - win.start) + (win.showTail ? 1 : 0) -} - export function QueuedMessages({ cols, queueEditIdx, diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 005d8cc4c9..04f42ec162 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -43,11 +43,16 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: return {spin.frames[frame]} } -type DetailRow = { color: string; content: ReactNode; dimColor?: boolean; key: string } +interface DetailRow { + color: string + content: ReactNode + dimColor?: boolean + key: string +} function Detail({ color, content, dimColor }: DetailRow) { return ( - + {content} @@ -141,7 +146,7 @@ export const Thinking = memo(function Thinking({ {preview ? ( - + {preview} @@ -158,7 +163,12 @@ export const Thinking = memo(function Thinking({ // ── ToolTrail ──────────────────────────────────────────────────────── -type Group = { color: string; content: ReactNode; details: DetailRow[]; key: string } +interface Group { + color: string + content: ReactNode + details: DetailRow[] + key: string +} export const ToolTrail = memo(function ToolTrail({ busy = false, diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index ca52ec91c5..3ab4be96ba 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,6 +1,5 @@ #!/usr/bin/env node import { render } from '@hermes/ink' -import React from 'react' import { App } from './app.js' import { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 50125d3b56..87adb2eb52 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -81,7 +81,3 @@ export function append(line: string): void { /* ignore */ } } - -export function all(): string[] { - return load() -} diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index ba1880ed3c..b17eff3eea 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -74,9 +74,17 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { } export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => { - const text = reasoning.replace(/\n/g, ' ').trim() + const raw = reasoning.trim() - return !text || mode === 'collapsed' ? '' : mode === 'full' ? text : compactPreview(text, max) + if (!raw || mode === 'collapsed') { + return '' + } + + if (mode === 'full') { + return raw + } + + return compactPreview(raw.replace(/\s+/g, ' '), max) } export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text) diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 3ae7ada19e..3ecb989bae 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -4,6 +4,8 @@ export interface ThemeColors { bronze: string cornsilk: string dim: string + completionBg: string + completionCurrentBg: string label: string ok: string @@ -39,6 +41,35 @@ export interface Theme { bannerHero: string } +// ── Color math ─────────────────────────────────────────────────────── + +function parseHex(h: string): [number, number, number] | null { + const m = /^#?([0-9a-f]{6})$/i.exec(h) + + if (!m) { + return null + } + + const n = parseInt(m[1]!, 16) + + return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff] +} + +function mix(a: string, b: string, t: number) { + const pa = parseHex(a) + const pb = parseHex(b) + + if (!pa || !pb) { + return a + } + + const lerp = (i: 0 | 1 | 2) => Math.round(pa[i] + (pb[i] - pa[i]) * t) + + return '#' + ((1 << 24) | (lerp(0) << 16) | (lerp(1) << 8) | lerp(2)).toString(16).slice(1) +} + +// ── Defaults ───────────────────────────────────────────────────────── + export const DEFAULT_THEME: Theme = { color: { gold: '#FFD700', @@ -46,8 +77,10 @@ export const DEFAULT_THEME: Theme = { bronze: '#CD7F32', cornsilk: '#FFF8DC', dim: '#B8860B', + completionBg: '#FFFFFF', + completionCurrentBg: mix('#FFFFFF', '#FFBF00', 0.25), - label: '#4dd0e1', + label: '#DAA520', ok: '#4caf50', error: '#ef5350', warn: '#ffa726', @@ -78,6 +111,8 @@ export const DEFAULT_THEME: Theme = { bannerHero: '' } +// ── Skin → Theme ───────────────────────────────────────────────────── + export function fromSkin( colors: Record, branding: Record, @@ -87,6 +122,8 @@ export function fromSkin( const d = DEFAULT_THEME const c = (k: string) => colors[k] + const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber + return { color: { gold: c('banner_title') ?? d.color.gold, @@ -94,6 +131,8 @@ export function fromSkin( bronze: c('banner_border') ?? d.color.bronze, cornsilk: c('banner_text') ?? d.color.cornsilk, dim: c('banner_dim') ?? d.color.dim, + completionBg: c('completion_menu_bg') ?? '#FFFFFF', + completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25), label: c('ui_label') ?? d.color.label, ok: c('ui_ok') ?? d.color.ok, From 33c615504d7256c015f0db34fcb59210d3dce773 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 10:20:56 -0500 Subject: [PATCH 109/157] feat: add inline token count etc and fix venv --- hermes_cli/main.py | 1 + tui_gateway/server.py | 8 +- ui-tui/README.md | 3 +- .../createGatewayEventHandler.test.ts | 354 +++++++++++++++++ ui-tui/src/__tests__/text.test.ts | 12 +- ui-tui/src/app.tsx | 17 +- ui-tui/src/app/createGatewayEventHandler.ts | 183 ++++----- ui-tui/src/app/createSlashHandler.ts | 47 +-- ui-tui/src/app/gatewayContext.tsx | 9 +- ui-tui/src/app/helpers.ts | 5 - ui-tui/src/app/interfaces.ts | 375 +++++++++++++++++- ui-tui/src/app/useComposerState.ts | 60 +-- ui-tui/src/app/useInputHandlers.ts | 52 +-- ui-tui/src/app/useTurnState.ts | 86 +--- ui-tui/src/components/appLayout.tsx | 71 +--- ui-tui/src/components/appOverlays.tsx | 15 +- ui-tui/src/components/messageLine.tsx | 9 +- ui-tui/src/components/thinking.tsx | 96 ++++- ui-tui/src/gatewayClient.ts | 36 +- ui-tui/src/lib/text.ts | 2 +- ui-tui/src/types.ts | 2 + 21 files changed, 984 insertions(+), 459 deletions(-) create mode 100644 ui-tui/src/__tests__/createGatewayEventHandler.test.ts diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 655e3903db..19d0633951 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -865,6 +865,7 @@ def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): env = os.environ.copy() env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get("HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT)) + env.setdefault("HERMES_PYTHON", sys.executable) env.setdefault("HERMES_CWD", os.getcwd()) if resume_session_id: env["HERMES_TUI_RESUME"] = resume_session_id diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 78cac4f880..e172cabc27 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -619,9 +619,13 @@ def _on_tool_progress( _args: dict | None = None, **_kwargs, ): - if not _tool_progress_enabled(sid) or event_type != "tool.started" or not name: + if not _tool_progress_enabled(sid): return - _emit("tool.progress", sid, {"name": name, "preview": preview or ""}) + if event_type == "tool.started" and name: + _emit("tool.progress", sid, {"name": name, "preview": preview or ""}) + return + if event_type == "reasoning.available" and preview and _reasoning_visible(sid): + _emit("reasoning.available", sid, {"text": str(preview)}) def _agent_cbs(sid: str) -> dict: diff --git a/ui-tui/README.md b/ui-tui/README.md index 19d162e6da..b4417d3cc3 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -16,7 +16,7 @@ The client entrypoint is `src/entry.tsx`. It exits early if `stdin` is not a TTY python -m tui_gateway.entry ``` -By default it uses `venv/bin/python` from the repo root. Set `HERMES_PYTHON` to override. +Interpreter resolution order is: `HERMES_PYTHON` → `PYTHON` → `$VIRTUAL_ENV/bin/python` → `./.venv/bin/python` → `./venv/bin/python` → `python3` (or `python` on Windows). The transport is newline-delimited JSON-RPC over stdio: @@ -224,6 +224,7 @@ Primary event types the client handles today: | `message.complete` | `{ text, rendered?, usage, status }` | | `thinking.delta` | `{ text }` | | `reasoning.delta` | `{ text }` | +| `reasoning.available` | `{ text }` | | `status.update` | `{ kind, text }` | | `tool.start` | `{ tool_id, name, context? }` | | `tool.progress` | `{ name, preview }` | diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts new file mode 100644 index 0000000000..86489e334a --- /dev/null +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -0,0 +1,354 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' +import { resetOverlayState } from '../app/overlayStore.js' +import { resetUiState } from '../app/uiStore.js' +import { estimateTokensRough } from '../lib/text.js' +import type { Msg } from '../types.js' + +const ref = (current: T) => ({ current }) + +describe('createGatewayEventHandler', () => { + beforeEach(() => { + resetOverlayState() + resetUiState() + }) + + it('persists completed tool rows when message.complete lands immediately after tool.complete', () => { + const appended: Msg[] = [] + + const state = { + activity: [] as unknown[], + reasoningTokens: 0, + streaming: '', + toolTokens: 0, + tools: [] as unknown[], + turnTrail: [] as string[] + } + + const setTools = vi.fn((next: unknown) => { + if (typeof next !== 'function') { + state.tools = next as unknown[] + } + }) + + const setTurnTrail = vi.fn((next: unknown) => { + if (typeof next !== 'function') { + state.turnTrail = next as string[] + } + }) + + const refs = { + activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), + bufRef: ref(''), + interruptedRef: ref(false), + lastStatusNoteRef: ref(''), + persistedToolLabelsRef: ref(new Set()), + protocolWarnedRef: ref(false), + reasoningRef: ref('mapped the page'), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const onEvent = createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(null), + sendQueued: vi.fn() + }, + gateway: { + gw: { request: vi.fn() } as any, + rpc: vi.fn(async () => null) + }, + session: { + STARTUP_RESUME_ID: '', + colsRef: ref(80), + newSession: vi.fn(), + resetSession: vi.fn(), + setCatalog: vi.fn() + }, + system: { + bellOnComplete: false, + sys: vi.fn() + }, + transcript: { + appendMessage: (msg: Msg) => appended.push(msg), + setHistoryItems: vi.fn(), + setMessages: vi.fn() + }, + turn: { + actions: { + clearReasoning: vi.fn(() => { + refs.reasoningRef.current = '' + refs.toolTokenAccRef.current = 0 + state.toolTokens = 0 + }), + endReasoningPhase: vi.fn(), + idle: vi.fn(() => { + refs.activeToolsRef.current = [] + state.tools = [] + }), + pruneTransient: vi.fn(), + pulseReasoningStreaming: vi.fn(), + pushActivity: vi.fn(), + pushTrail: vi.fn(), + scheduleReasoning: vi.fn(), + scheduleStreaming: vi.fn(), + setActivity: vi.fn(), + setReasoningTokens: vi.fn((next: number) => { + state.reasoningTokens = next + }), + setStreaming: vi.fn((next: string) => { + state.streaming = next + }), + setToolTokens: vi.fn((next: number) => { + state.toolTokens = next + }), + setTools, + setTurnTrail + }, + refs + } + } as any) + + onEvent({ + payload: { context: 'home page', name: 'search', tool_id: 'tool-1' }, + type: 'tool.start' + } as any) + onEvent({ + payload: { name: 'search', preview: 'hero cards' }, + type: 'tool.progress' + } as any) + onEvent({ + payload: { summary: 'done', tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) + onEvent({ + payload: { text: 'final answer' }, + type: 'message.complete' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]).toMatchObject({ + role: 'assistant', + text: 'final answer', + thinking: 'mapped the page' + }) + expect(appended[0]?.tools).toHaveLength(1) + expect(appended[0]?.tools?.[0]).toContain('hero cards') + expect(appended[0]?.toolTokens).toBeGreaterThan(0) + }) + + it('keeps tool tokens across handler recreation mid-turn', () => { + const appended: Msg[] = [] + + const state = { + activity: [] as unknown[], + reasoningTokens: 0, + streaming: '', + toolTokens: 0, + tools: [] as unknown[], + turnTrail: [] as string[] + } + + const refs = { + activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), + bufRef: ref(''), + interruptedRef: ref(false), + lastStatusNoteRef: ref(''), + persistedToolLabelsRef: ref(new Set()), + protocolWarnedRef: ref(false), + reasoningRef: ref('mapped the page'), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const buildHandler = () => + createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(null), + sendQueued: vi.fn() + }, + gateway: { + gw: { request: vi.fn() } as any, + rpc: vi.fn(async () => null) + }, + session: { + STARTUP_RESUME_ID: '', + colsRef: ref(80), + newSession: vi.fn(), + resetSession: vi.fn(), + setCatalog: vi.fn() + }, + system: { + bellOnComplete: false, + sys: vi.fn() + }, + transcript: { + appendMessage: (msg: Msg) => appended.push(msg), + setHistoryItems: vi.fn(), + setMessages: vi.fn() + }, + turn: { + actions: { + clearReasoning: vi.fn(() => { + refs.reasoningRef.current = '' + refs.toolTokenAccRef.current = 0 + state.toolTokens = 0 + }), + endReasoningPhase: vi.fn(), + idle: vi.fn(() => { + refs.activeToolsRef.current = [] + state.tools = [] + }), + pruneTransient: vi.fn(), + pulseReasoningStreaming: vi.fn(), + pushActivity: vi.fn(), + pushTrail: vi.fn(), + scheduleReasoning: vi.fn(), + scheduleStreaming: vi.fn(), + setActivity: vi.fn(), + setReasoningTokens: vi.fn((next: number) => { + state.reasoningTokens = next + }), + setStreaming: vi.fn((next: string) => { + state.streaming = next + }), + setToolTokens: vi.fn((next: number) => { + state.toolTokens = next + }), + setTools: vi.fn((next: unknown) => { + if (typeof next !== 'function') { + state.tools = next as unknown[] + } + }), + setTurnTrail: vi.fn((next: unknown) => { + if (typeof next !== 'function') { + state.turnTrail = next as string[] + } + }) + }, + refs + } + } as any) + + buildHandler()({ + payload: { context: 'home page', name: 'search', tool_id: 'tool-1' }, + type: 'tool.start' + } as any) + + const onEvent = buildHandler() + + onEvent({ + payload: { name: 'search', preview: 'hero cards' }, + type: 'tool.progress' + } as any) + onEvent({ + payload: { summary: 'done', tool_id: 'tool-1' }, + type: 'tool.complete' + } as any) + onEvent({ + payload: { text: 'final answer' }, + type: 'message.complete' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]?.tools).toHaveLength(1) + expect(appended[0]?.toolTokens).toBeGreaterThan(0) + }) + + it('ignores fallback reasoning.available when streamed reasoning already exists', () => { + const appended: Msg[] = [] + const streamed = 'short streamed reasoning' + const fallback = 'x'.repeat(400) + + const refs = { + activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), + bufRef: ref(''), + interruptedRef: ref(false), + lastStatusNoteRef: ref(''), + persistedToolLabelsRef: ref(new Set()), + protocolWarnedRef: ref(false), + reasoningRef: ref(''), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const onEvent = createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(null), + sendQueued: vi.fn() + }, + gateway: { + gw: { request: vi.fn() } as any, + rpc: vi.fn(async () => null) + }, + session: { + STARTUP_RESUME_ID: '', + colsRef: ref(80), + newSession: vi.fn(), + resetSession: vi.fn(), + setCatalog: vi.fn() + }, + system: { + bellOnComplete: false, + sys: vi.fn() + }, + transcript: { + appendMessage: (msg: Msg) => appended.push(msg), + setHistoryItems: vi.fn(), + setMessages: vi.fn() + }, + turn: { + actions: { + clearReasoning: vi.fn(() => { + refs.reasoningRef.current = '' + refs.toolTokenAccRef.current = 0 + }), + endReasoningPhase: vi.fn(), + idle: vi.fn(() => { + refs.activeToolsRef.current = [] + }), + pruneTransient: vi.fn(), + pulseReasoningStreaming: vi.fn(), + pushActivity: vi.fn(), + pushTrail: vi.fn(), + scheduleReasoning: vi.fn(), + scheduleStreaming: vi.fn(), + setActivity: vi.fn(), + setReasoningTokens: vi.fn(), + setStreaming: vi.fn(), + setToolTokens: vi.fn(), + setTools: vi.fn(), + setTurnTrail: vi.fn() + }, + refs + } + } as any) + + onEvent({ + payload: { text: streamed }, + type: 'reasoning.delta' + } as any) + onEvent({ + payload: { text: fallback }, + type: 'reasoning.available' + } as any) + onEvent({ + payload: { text: 'final answer' }, + type: 'message.complete' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]?.thinking).toBe(streamed) + expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed)) + }) +}) diff --git a/ui-tui/src/__tests__/text.test.ts b/ui-tui/src/__tests__/text.test.ts index 181b96b43f..0a11e3cc06 100644 --- a/ui-tui/src/__tests__/text.test.ts +++ b/ui-tui/src/__tests__/text.test.ts @@ -48,14 +48,14 @@ describe('fmtK', () => { expect(fmtK(999)).toBe('999') }) - it('formats thousands as K', () => { - expect(fmtK(1000)).toBe('1K') - expect(fmtK(1500)).toBe('1.5K') + it('formats thousands as lowercase k', () => { + expect(fmtK(1000)).toBe('1k') + expect(fmtK(1500)).toBe('1.5k') }) - it('formats millions and billions', () => { - expect(fmtK(1_000_000)).toBe('1M') - expect(fmtK(1_000_000_000)).toBe('1B') + it('formats millions and billions with lowercase suffixes', () => { + expect(fmtK(1_000_000)).toBe('1m') + expect(fmtK(1_000_000_000)).toBe('1b') }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 79edcce289..d071ba7867 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -849,11 +849,14 @@ export function App({ gw }: { gw: GatewayClient }) { scheduleReasoning: turnActions.scheduleReasoning, scheduleStreaming: turnActions.scheduleStreaming, setActivity: turnActions.setActivity, + setReasoningTokens: turnActions.setReasoningTokens, setStreaming: turnActions.setStreaming, + setToolTokens: turnActions.setToolTokens, setTools: turnActions.setTools, setTurnTrail: turnActions.setTurnTrail }, refs: { + activeToolsRef: turnRefs.activeToolsRef, bufRef: turnRefs.bufRef, interruptedRef: turnRefs.interruptedRef, lastStatusNoteRef: turnRefs.lastStatusNoteRef, @@ -861,6 +864,7 @@ export function App({ gw }: { gw: GatewayClient }) { protocolWarnedRef: turnRefs.protocolWarnedRef, reasoningRef: turnRefs.reasoningRef, statusTimerRef: turnRefs.statusTimerRef, + toolTokenAccRef: turnRefs.toolTokenAccRef, toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, turnToolsRef: turnRefs.turnToolsRef } @@ -1014,16 +1018,7 @@ export function App({ gw }: { gw: GatewayClient }) { dispatchSubmission([...composerState.inputBuf, value].join('\n')) }, - [ - appendMessage, - composerActions, - composerRefs, - composerState, - dispatchSubmission, - gw, - sys, - turnActions - ] + [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions] ) submitRef.current = submit @@ -1142,11 +1137,13 @@ export function App({ gw }: { gw: GatewayClient }) { progress={{ activity: turnState.activity, reasoning: turnState.reasoning, + reasoningTokens: turnState.reasoningTokens, reasoningActive: turnState.reasoningActive, reasoningStreaming: turnState.reasoningStreaming, showProgressArea, showStreamingArea, streaming: turnState.streaming, + toolTokens: turnState.toolTokens, tools: turnState.tools, turnTrail: turnState.turnTrail }} diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 8c3158017c..6afd5c094f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,72 +1,18 @@ -import type { Dispatch, MutableRefObject, SetStateAction } from 'react' - import type { GatewayEvent } from '../gatewayClient.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import { buildToolTrailLine, isToolTrailResultLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import { + buildToolTrailLine, + estimateTokensRough, + isToolTrailResultLine, + sameToolTrailGroup, + toolTrailLabel +} from '../lib/text.js' import { fromSkin } from '../theme.js' -import type { Msg, SlashCatalog } from '../types.js' import { introMsg, toTranscriptMessages } from './helpers.js' -import type { GatewayServices } from './interfaces.js' +import type { GatewayEventHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' import { getUiState, patchUiState } from './uiStore.js' -import type { TurnActions, TurnRefs } from './useTurnState.js' - -export interface GatewayEventHandlerContext { - composer: { - dequeue: () => string | undefined - queueEditRef: MutableRefObject - sendQueued: (text: string) => void - } - gateway: GatewayServices - session: { - STARTUP_RESUME_ID: string - colsRef: MutableRefObject - newSession: (msg?: string) => void - resetSession: () => void - setCatalog: Dispatch> - } - system: { - bellOnComplete: boolean - stdout?: NodeJS.WriteStream - sys: (text: string) => void - } - transcript: { - appendMessage: (msg: Msg) => void - setHistoryItems: Dispatch> - setMessages: Dispatch> - } - turn: { - actions: Pick< - TurnActions, - | 'clearReasoning' - | 'endReasoningPhase' - | 'idle' - | 'pruneTransient' - | 'pulseReasoningStreaming' - | 'pushActivity' - | 'pushTrail' - | 'scheduleReasoning' - | 'scheduleStreaming' - | 'setActivity' - | 'setStreaming' - | 'setTools' - | 'setTurnTrail' - > - refs: Pick< - TurnRefs, - | 'bufRef' - | 'interruptedRef' - | 'lastStatusNoteRef' - | 'persistedToolLabelsRef' - | 'protocolWarnedRef' - | 'reasoningRef' - | 'statusTimerRef' - | 'toolCompleteRibbonRef' - | 'turnToolsRef' - > - } -} export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { const { dequeue, queueEditRef, sendQueued } = ctx.composer @@ -86,12 +32,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: scheduleReasoning, scheduleStreaming, setActivity, + setReasoningTokens, setStreaming, + setToolTokens, setTools, setTurnTrail } = ctx.turn.actions const { + activeToolsRef, bufRef, interruptedRef, lastStatusNoteRef, @@ -99,6 +48,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: protocolWarnedRef, reasoningRef, statusTimerRef, + toolTokenAccRef, toolCompleteRibbonRef, turnToolsRef } = ctx.turn.refs @@ -210,8 +160,12 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: clearReasoning() setActivity([]) setTurnTrail([]) + activeToolsRef.current = [] + setTools([]) turnToolsRef.current = [] persistedToolLabelsRef.current.clear() + toolTokenAccRef.current = 0 + setToolTokens(0) break @@ -286,21 +240,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'reasoning.delta': if (p?.text) { reasoningRef.current += p.text + setReasoningTokens(estimateTokensRough(reasoningRef.current)) scheduleReasoning() pulseReasoningStreaming() } break + case 'reasoning.available': { + const incoming = String(p?.text ?? '').trim() + + if (!incoming) { + break + } + + const current = reasoningRef.current.trim() + + // `reasoning.available` is a backend fallback preview that can arrive after + // streamed reasoning. Preserve the live-visible reasoning/counts if we + // already saw deltas; only hydrate from this event when streaming gave us + // nothing. + if (!current) { + reasoningRef.current = incoming + setReasoningTokens(estimateTokensRough(reasoningRef.current)) + scheduleReasoning() + pulseReasoningStreaming() + } + + break + } case 'tool.progress': if (p?.preview) { - setTools(prev => { - const index = prev.findIndex(tool => tool.name === p.name) + const index = activeToolsRef.current.findIndex(tool => tool.name === p.name) - return index >= 0 - ? [...prev.slice(0, index), { ...prev[index]!, context: p.preview as string }, ...prev.slice(index + 1)] - : prev - }) + if (index >= 0) { + const next = [...activeToolsRef.current] + + next[index] = { ...next[index]!, context: p.preview as string } + activeToolsRef.current = next + setTools(next) + } } break @@ -311,44 +290,47 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } break - - case 'tool.start': + case 'tool.start': { pruneTransient() endReasoningPhase() - setTools(prev => [ - ...prev, - { id: p.tool_id, name: p.name, context: (p.context as string) || '', startedAt: Date.now() } - ]) + const ctx = (p.context as string) || '' + const sample = `${String(p.name ?? '')} ${ctx}`.trim() + toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0 + setToolTokens(toolTokenAccRef.current) + activeToolsRef.current = [ + ...activeToolsRef.current, + { id: p.tool_id, name: p.name, context: ctx, startedAt: Date.now() } + ] + setTools(activeToolsRef.current) break + } + case 'tool.complete': { toolCompleteRibbonRef.current = null - setTools(prev => { - const done = prev.find(tool => tool.id === p.tool_id) - const name = done?.name ?? p.name - const label = toolTrailLabel(name) + const done = activeToolsRef.current.find(tool => tool.id === p.tool_id) + const name = done?.name ?? p.name + const label = toolTrailLabel(name) - const line = buildToolTrailLine( - name, - done?.context || '', - !!p.error, - (p.error as string) || (p.summary as string) || '' - ) + const line = buildToolTrailLine( + name, + done?.context || '', + !!p.error, + (p.error as string) || (p.summary as string) || '' + ) - const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line] - const remaining = prev.filter(tool => tool.id !== p.tool_id) + const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line] - toolCompleteRibbonRef.current = { label, line } + activeToolsRef.current = activeToolsRef.current.filter(tool => tool.id !== p.tool_id) + setTools(activeToolsRef.current) + toolCompleteRibbonRef.current = { label, line } - if (!remaining.length) { - next.push('analyzing tool output…') - } + if (!activeToolsRef.current.length) { + next.push('analyzing tool output…') + } - turnToolsRef.current = next.slice(-8) - setTurnTrail(turnToolsRef.current) - - return remaining - }) + turnToolsRef.current = next.slice(-8) + setTurnTrail(turnToolsRef.current) if (p?.inline_diff) { sys(p.inline_diff as string) @@ -419,6 +401,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart() const persisted = persistedToolLabelsRef.current const savedReasoning = reasoningRef.current.trim() + const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 + const savedToolTokens = toolTokenAccRef.current const savedTools = turnToolsRef.current.filter( line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line)) @@ -426,15 +410,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const wasInterrupted = interruptedRef.current - idle() - clearReasoning() - setStreaming('') - if (!wasInterrupted) { appendMessage({ role: 'assistant', text: finalText, thinking: savedReasoning || undefined, + thinkingTokens: savedReasoning ? savedReasoningTokens : undefined, + toolTokens: savedTools.length ? savedToolTokens : undefined, tools: savedTools.length ? savedTools : undefined }) @@ -443,6 +425,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } } + idle() + clearReasoning() + turnToolsRef.current = [] persistedToolLabelsRef.current.clear() setActivity([]) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 9f5df4ca92..eb8fd7eb5f 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,57 +1,14 @@ -import type { Dispatch, MutableRefObject, SetStateAction } from 'react' - import { HOTKEYS } from '../constants.js' import { writeOsc52Clipboard } from '../lib/osc52.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { fmtK } from '../lib/text.js' -import type { DetailsMode, Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js' +import type { DetailsMode, PanelSection } from '../types.js' import { imageTokenMeta, introMsg, nextDetailsMode, parseDetailsMode, toTranscriptMessages } from './helpers.js' -import type { GatewayServices } from './interfaces.js' +import type { SlashHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' import { getUiState, patchUiState } from './uiStore.js' -export interface SlashHandlerContext { - composer: { - enqueue: (text: string) => void - hasSelection: boolean - paste: (quiet?: boolean) => void - queueRef: MutableRefObject - selection: { - copySelection: () => string - } - setInput: Dispatch> - } - gateway: GatewayServices - local: { - catalog: SlashCatalog | null - lastUserMsg: string - maybeWarn: (value: any) => void - messages: Msg[] - } - session: { - closeSession: (targetSid?: string | null) => Promise - die: () => void - guardBusySessionSwitch: (what?: string) => boolean - newSession: (msg?: string) => void - resetVisibleHistory: (info?: SessionInfo | null) => void - resumeById: (id: string) => void - setSessionStartedAt: Dispatch> - } - transcript: { - page: (text: string, title?: string) => void - panel: (title: string, sections: PanelSection[]) => void - send: (text: string) => void - setHistoryItems: Dispatch> - setMessages: Dispatch> - sys: (text: string) => void - trimLastExchange: (items: Msg[]) => Msg[] - } - voice: { - setVoiceEnabled: Dispatch> - } -} - export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer const { gw, rpc } = ctx.gateway diff --git a/ui-tui/src/app/gatewayContext.tsx b/ui-tui/src/app/gatewayContext.tsx index cdd9347fb0..9187f15a3a 100644 --- a/ui-tui/src/app/gatewayContext.tsx +++ b/ui-tui/src/app/gatewayContext.tsx @@ -1,14 +1,9 @@ -import { createContext, type ReactNode, useContext } from 'react' +import { createContext, useContext } from 'react' -import type { GatewayServices } from './interfaces.js' +import type { GatewayProviderProps, GatewayServices } from './interfaces.js' const GatewayContext = createContext(null) -export interface GatewayProviderProps { - children: ReactNode - value: GatewayServices -} - export function GatewayProvider({ children, value }: GatewayProviderProps) { return {children} } diff --git a/ui-tui/src/app/helpers.ts b/ui-tui/src/app/helpers.ts index 350687d741..8496008c7a 100644 --- a/ui-tui/src/app/helpers.ts +++ b/ui-tui/src/app/helpers.ts @@ -3,11 +3,6 @@ import type { DetailsMode, Msg, SessionInfo } from '../types.js' const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] -export interface PasteSnippet { - label: string - text: string -} - export const parseDetailsMode = (v: unknown): DetailsMode | null => { const s = typeof v === 'string' ? v.trim().toLowerCase() : '' diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index c4611f9dc4..549e85fe24 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -1,6 +1,32 @@ +import type { ScrollBoxHandle } from '@hermes/ink' +import type { MutableRefObject, ReactNode, RefObject, SetStateAction } from 'react' + +import type { PasteEvent } from '../components/textInput.js' import type { GatewayClient } from '../gatewayClient.js' +import type { RpcResult } from '../lib/rpc.js' import type { Theme } from '../theme.js' -import type { ApprovalReq, ClarifyReq, DetailsMode, Msg, SecretReq, SessionInfo, SudoReq, Usage } from '../types.js' +import type { + ActiveTool, + ActivityItem, + ApprovalReq, + ClarifyReq, + DetailsMode, + Msg, + PanelSection, + SecretReq, + SessionInfo, + SlashCatalog, + SudoReq, + Usage +} from '../types.js' + +export interface StateSetter { + (value: SetStateAction): void +} + +export interface SelectionApi { + copySelection: () => string +} export interface CompletionItem { display: string @@ -9,7 +35,7 @@ export interface CompletionItem { } export interface GatewayRpc { - (method: string, params?: Record): Promise + (method: string, params?: Record): Promise } export interface GatewayServices { @@ -17,6 +43,11 @@ export interface GatewayServices { rpc: GatewayRpc } +export interface GatewayProviderProps { + children: ReactNode + value: GatewayServices +} + export interface OverlayState { approval: ApprovalReq | null clarify: ClarifyReq | null @@ -65,3 +96,343 @@ export interface VirtualHistoryState { start: number topSpacer: number } + +export interface ComposerPasteResult { + cursor: number + value: string +} + +export interface ComposerActions { + clearIn: () => void + dequeue: () => string | undefined + enqueue: (text: string) => void + handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null + openEditor: () => void + pushHistory: (text: string) => void + replaceQueue: (index: number, text: string) => void + setCompIdx: StateSetter + setHistoryIdx: StateSetter + setInput: StateSetter + setInputBuf: StateSetter + setPasteSnips: StateSetter + setQueueEdit: (index: number | null) => void + syncQueue: () => void +} + +export interface ComposerRefs { + historyDraftRef: MutableRefObject + historyRef: MutableRefObject + queueEditRef: MutableRefObject + queueRef: MutableRefObject + submitRef: MutableRefObject<(value: string) => void> +} + +export interface ComposerState { + compIdx: number + compReplace: number + completions: CompletionItem[] + historyIdx: number | null + input: string + inputBuf: string[] + pasteSnips: PasteSnippet[] + queueEditIdx: number | null + queuedDisplay: string[] +} + +export interface UseComposerStateOptions { + gw: GatewayClient + onClipboardPaste: (quiet?: boolean) => Promise | void + submitRef: MutableRefObject<(value: string) => void> +} + +export interface UseComposerStateResult { + actions: ComposerActions + refs: ComposerRefs + state: ComposerState +} + +export interface InterruptTurnOptions { + appendMessage: (msg: Msg) => void + gw: { request: (method: string, params?: Record) => Promise } + sid: string + sys: (text: string) => void +} + +export interface TurnActions { + clearReasoning: () => void + endReasoningPhase: () => void + idle: () => void + interruptTurn: (options: InterruptTurnOptions) => void + pruneTransient: () => void + pulseReasoningStreaming: () => void + pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void + pushTrail: (line: string) => void + scheduleReasoning: () => void + scheduleStreaming: () => void + setActivity: StateSetter + setReasoning: StateSetter + setReasoningTokens: StateSetter + setReasoningActive: StateSetter + setToolTokens: StateSetter + setReasoningStreaming: StateSetter + setStreaming: StateSetter + setTools: StateSetter + setTurnTrail: StateSetter +} + +export interface TurnRefs { + activeToolsRef: MutableRefObject + bufRef: MutableRefObject + interruptedRef: MutableRefObject + lastStatusNoteRef: MutableRefObject + persistedToolLabelsRef: MutableRefObject> + protocolWarnedRef: MutableRefObject + reasoningRef: MutableRefObject + reasoningStreamingTimerRef: MutableRefObject | null> + reasoningTimerRef: MutableRefObject | null> + statusTimerRef: MutableRefObject | null> + streamTimerRef: MutableRefObject | null> + toolTokenAccRef: MutableRefObject + toolCompleteRibbonRef: MutableRefObject + turnToolsRef: MutableRefObject +} + +export interface TurnState { + activity: ActivityItem[] + reasoning: string + reasoningTokens: number + reasoningActive: boolean + reasoningStreaming: boolean + streaming: string + toolTokens: number + tools: ActiveTool[] + turnTrail: string[] +} + +export interface UseTurnStateResult { + actions: TurnActions + refs: TurnRefs + state: TurnState +} + +export interface InputHandlerActions { + answerClarify: (answer: string) => void + appendMessage: (msg: Msg) => void + die: () => void + dispatchSubmission: (full: string) => void + guardBusySessionSwitch: (what?: string) => boolean + newSession: (msg?: string) => void + sys: (text: string) => void +} + +export interface InputHandlerContext { + actions: InputHandlerActions + composer: { + actions: ComposerActions + refs: ComposerRefs + state: ComposerState + } + gateway: GatewayServices + terminal: { + hasSelection: boolean + scrollRef: RefObject + scrollWithSelection: (delta: number) => void + selection: SelectionApi + stdout?: NodeJS.WriteStream + } + turn: { + actions: TurnActions + refs: TurnRefs + } + voice: { + recording: boolean + setProcessing: StateSetter + setRecording: StateSetter + } + wheelStep: number +} + +export interface InputHandlerResult { + pagerPageSize: number +} + +export interface GatewayEventHandlerContext { + composer: { + dequeue: () => string | undefined + queueEditRef: MutableRefObject + sendQueued: (text: string) => void + } + gateway: GatewayServices + session: { + STARTUP_RESUME_ID: string + colsRef: MutableRefObject + newSession: (msg?: string) => void + resetSession: () => void + setCatalog: StateSetter + } + system: { + bellOnComplete: boolean + stdout?: NodeJS.WriteStream + sys: (text: string) => void + } + transcript: { + appendMessage: (msg: Msg) => void + setHistoryItems: StateSetter + setMessages: StateSetter + } + turn: { + actions: Pick< + TurnActions, + | 'clearReasoning' + | 'endReasoningPhase' + | 'idle' + | 'pruneTransient' + | 'pulseReasoningStreaming' + | 'pushActivity' + | 'pushTrail' + | 'scheduleReasoning' + | 'scheduleStreaming' + | 'setActivity' + | 'setReasoningTokens' + | 'setStreaming' + | 'setToolTokens' + | 'setTools' + | 'setTurnTrail' + > + refs: Pick< + TurnRefs, + | 'activeToolsRef' + | 'bufRef' + | 'interruptedRef' + | 'lastStatusNoteRef' + | 'persistedToolLabelsRef' + | 'protocolWarnedRef' + | 'reasoningRef' + | 'statusTimerRef' + | 'toolTokenAccRef' + | 'toolCompleteRibbonRef' + | 'turnToolsRef' + > + } +} + +export interface SlashHandlerContext { + composer: { + enqueue: (text: string) => void + hasSelection: boolean + paste: (quiet?: boolean) => void + queueRef: MutableRefObject + selection: SelectionApi + setInput: StateSetter + } + gateway: GatewayServices + local: { + catalog: SlashCatalog | null + lastUserMsg: string + maybeWarn: (value: any) => void + messages: Msg[] + } + session: { + closeSession: (targetSid?: string | null) => Promise + die: () => void + guardBusySessionSwitch: (what?: string) => boolean + newSession: (msg?: string) => void + resetVisibleHistory: (info?: SessionInfo | null) => void + resumeById: (id: string) => void + setSessionStartedAt: StateSetter + } + transcript: { + page: (text: string, title?: string) => void + panel: (title: string, sections: PanelSection[]) => void + send: (text: string) => void + setHistoryItems: StateSetter + setMessages: StateSetter + sys: (text: string) => void + trimLastExchange: (items: Msg[]) => Msg[] + } + voice: { + setVoiceEnabled: StateSetter + } +} + +export interface AppLayoutActions { + answerApproval: (choice: string) => void + answerClarify: (answer: string) => void + answerSecret: (value: string) => void + answerSudo: (pw: string) => void + onModelSelect: (value: string) => void + resumeById: (id: string) => void + setStickyPrompt: (value: string) => void +} + +export interface AppLayoutComposerProps { + cols: number + compIdx: number + completions: CompletionItem[] + empty: boolean + handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null + input: string + inputBuf: string[] + pagerPageSize: number + queueEditIdx: number | null + queuedDisplay: string[] + submit: (value: string) => void + updateInput: StateSetter +} + +export interface AppLayoutProgressProps { + activity: ActivityItem[] + reasoning: string + reasoningTokens: number + reasoningActive: boolean + reasoningStreaming: boolean + showProgressArea: boolean + showStreamingArea: boolean + streaming: string + toolTokens: number + tools: ActiveTool[] + turnTrail: string[] +} + +export interface AppLayoutStatusProps { + cwdLabel: string + durationLabel: string + showStickyPrompt: boolean + statusColor: string + stickyPrompt: string + voiceLabel: string +} + +export interface AppLayoutTranscriptProps { + historyItems: Msg[] + scrollRef: RefObject + virtualHistory: VirtualHistoryState + virtualRows: TranscriptRow[] +} + +export interface AppLayoutProps { + actions: AppLayoutActions + composer: AppLayoutComposerProps + mouseTracking: boolean + progress: AppLayoutProgressProps + status: AppLayoutStatusProps + transcript: AppLayoutTranscriptProps +} + +export interface AppOverlaysProps { + cols: number + compIdx: number + completions: CompletionItem[] + onApprovalChoice: (choice: string) => void + onClarifyAnswer: (value: string) => void + onModelSelect: (value: string) => void + onPickerSelect: (sessionId: string) => void + onSecretSubmit: (value: string) => void + onSudoSubmit: (pw: string) => void + pagerPageSize: number +} + +export interface PasteSnippet { + label: string + text: string +} diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 7e8b317534..8d3df69ee0 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -4,74 +4,18 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { useStore } from '@nanostores/react' -import { type Dispatch, type MutableRefObject, type SetStateAction, useCallback, useState } from 'react' +import { useCallback, useState } from 'react' import type { PasteEvent } from '../components/textInput.js' -import type { GatewayClient } from '../gatewayClient.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' import { LARGE_PASTE } from './constants.js' -import type { PasteSnippet } from './helpers.js' -import type { CompletionItem } from './interfaces.js' +import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' -export interface ComposerPasteResult { - cursor: number - value: string -} - -export interface ComposerActions { - clearIn: () => void - dequeue: () => string | undefined - enqueue: (text: string) => void - handleTextPaste: (event: PasteEvent) => ComposerPasteResult | null - openEditor: () => void - pushHistory: (text: string) => void - replaceQueue: (index: number, text: string) => void - setCompIdx: Dispatch> - setHistoryIdx: Dispatch> - setInput: Dispatch> - setInputBuf: Dispatch> - setPasteSnips: Dispatch> - setQueueEdit: (index: number | null) => void - syncQueue: () => void -} - -export interface ComposerRefs { - historyDraftRef: MutableRefObject - historyRef: MutableRefObject - queueEditRef: MutableRefObject - queueRef: MutableRefObject - submitRef: MutableRefObject<(value: string) => void> -} - -export interface ComposerState { - compIdx: number - compReplace: number - completions: CompletionItem[] - historyIdx: number | null - input: string - inputBuf: string[] - pasteSnips: PasteSnippet[] - queueEditIdx: number | null - queuedDisplay: string[] -} - -export interface UseComposerStateOptions { - gw: GatewayClient - onClipboardPaste: (quiet?: boolean) => Promise | void - submitRef: MutableRefObject<(value: string) => void> -} - -export interface UseComposerStateResult { - actions: ComposerActions - refs: ComposerRefs - state: ComposerState -} - export function useComposerState({ gw, onClipboardPaste, submitRef }: UseComposerStateOptions): UseComposerStateResult { const [input, setInput] = useState('') const [inputBuf, setInputBuf] = useState([]) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 1db4594b94..3f23d3e6c1 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -1,57 +1,9 @@ -import { type ScrollBoxHandle, useInput } from '@hermes/ink' +import { useInput } from '@hermes/ink' import { useStore } from '@nanostores/react' -import type { Dispatch, RefObject, SetStateAction } from 'react' -import type { Msg } from '../types.js' - -import type { GatewayServices } from './interfaces.js' +import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { getUiState, patchUiState } from './uiStore.js' -import type { ComposerActions, ComposerRefs, ComposerState } from './useComposerState.js' -import type { TurnActions, TurnRefs } from './useTurnState.js' - -export interface InputHandlerActions { - answerClarify: (answer: string) => void - appendMessage: (msg: Msg) => void - die: () => void - dispatchSubmission: (full: string) => void - guardBusySessionSwitch: (what?: string) => boolean - newSession: (msg?: string) => void - sys: (text: string) => void -} - -export interface InputHandlerContext { - actions: InputHandlerActions - composer: { - actions: ComposerActions - refs: ComposerRefs - state: ComposerState - } - gateway: GatewayServices - terminal: { - hasSelection: boolean - scrollRef: RefObject - scrollWithSelection: (delta: number) => void - selection: { - copySelection: () => string - } - stdout?: NodeJS.WriteStream - } - turn: { - actions: TurnActions - refs: TurnRefs - } - voice: { - recording: boolean - setProcessing: Dispatch> - setRecording: Dispatch> - } - wheelStep: number -} - -export interface InputHandlerResult { - pagerPageSize: number -} export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts index a6a611bc60..e78b7f489c 100644 --- a/ui-tui/src/app/useTurnState.ts +++ b/ui-tui/src/app/useTurnState.ts @@ -1,89 +1,26 @@ -import { - type Dispatch, - type MutableRefObject, - type SetStateAction, - useCallback, - useEffect, - useRef, - useState -} from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' -import type { ActiveTool, ActivityItem, Msg } from '../types.js' +import type { ActiveTool, ActivityItem } from '../types.js' import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js' -import type { ToolCompleteRibbon } from './interfaces.js' +import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js' import { resetOverlayState } from './overlayStore.js' import { patchUiState } from './uiStore.js' -export interface InterruptTurnOptions { - appendMessage: (msg: Msg) => void - gw: { request: (method: string, params?: Record) => Promise } - sid: string - sys: (text: string) => void -} - -export interface TurnActions { - clearReasoning: () => void - endReasoningPhase: () => void - idle: () => void - interruptTurn: (options: InterruptTurnOptions) => void - pruneTransient: () => void - pulseReasoningStreaming: () => void - pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void - pushTrail: (line: string) => void - scheduleReasoning: () => void - scheduleStreaming: () => void - setActivity: Dispatch> - setReasoning: Dispatch> - setReasoningActive: Dispatch> - setReasoningStreaming: Dispatch> - setStreaming: Dispatch> - setTools: Dispatch> - setTurnTrail: Dispatch> -} - -export interface TurnRefs { - bufRef: MutableRefObject - interruptedRef: MutableRefObject - lastStatusNoteRef: MutableRefObject - persistedToolLabelsRef: MutableRefObject> - protocolWarnedRef: MutableRefObject - reasoningRef: MutableRefObject - reasoningStreamingTimerRef: MutableRefObject | null> - reasoningTimerRef: MutableRefObject | null> - statusTimerRef: MutableRefObject | null> - streamTimerRef: MutableRefObject | null> - toolCompleteRibbonRef: MutableRefObject - turnToolsRef: MutableRefObject -} - -export interface TurnState { - activity: ActivityItem[] - reasoning: string - reasoningActive: boolean - reasoningStreaming: boolean - streaming: string - tools: ActiveTool[] - turnTrail: string[] -} - -export interface UseTurnStateResult { - actions: TurnActions - refs: TurnRefs - state: TurnState -} - export function useTurnState(): UseTurnStateResult { const [activity, setActivity] = useState([]) const [reasoning, setReasoning] = useState('') + const [reasoningTokens, setReasoningTokens] = useState(0) const [reasoningActive, setReasoningActive] = useState(false) + const [toolTokens, setToolTokens] = useState(0) const [reasoningStreaming, setReasoningStreaming] = useState(false) const [streaming, setStreaming] = useState('') const [tools, setTools] = useState([]) const [turnTrail, setTurnTrail] = useState([]) const activityIdRef = useRef(0) + const activeToolsRef = useRef([]) const bufRef = useRef('') const interruptedRef = useRef(false) const lastStatusNoteRef = useRef('') @@ -94,6 +31,7 @@ export function useTurnState(): UseTurnStateResult { const reasoningTimerRef = useRef | null>(null) const statusTimerRef = useRef | null>(null) const streamTimerRef = useRef | null>(null) + const toolTokenAccRef = useRef(0) const toolCompleteRibbonRef = useRef(null) const turnToolsRef = useRef([]) @@ -200,11 +138,15 @@ export function useTurnState(): UseTurnStateResult { } reasoningRef.current = '' + toolTokenAccRef.current = 0 setReasoning('') + setReasoningTokens(0) + setToolTokens(0) }, []) const idle = useCallback(() => { endReasoningPhase() + activeToolsRef.current = [] setTools([]) setTurnTrail([]) patchUiState({ busy: false }) @@ -263,13 +205,16 @@ export function useTurnState(): UseTurnStateResult { scheduleStreaming, setActivity, setReasoning, + setReasoningTokens, setReasoningActive, + setToolTokens, setReasoningStreaming, setStreaming, setTools, setTurnTrail }, refs: { + activeToolsRef, bufRef, interruptedRef, lastStatusNoteRef, @@ -280,13 +225,16 @@ export function useTurnState(): UseTurnStateResult { reasoningTimerRef, statusTimerRef, streamTimerRef, + toolTokenAccRef, toolCompleteRibbonRef, turnToolsRef }, state: { activity, reasoning, + reasoningTokens, reasoningActive, + toolTokens, reasoningStreaming, streaming, tools, diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index be33502ee9..46bd330c1a 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,84 +1,19 @@ -import { AlternateScreen, Box, NoSelect, ScrollBox, type ScrollBoxHandle, Text } from '@hermes/ink' +import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' -import type { RefObject } from 'react' import { PLACEHOLDER } from '../app/constants.js' -import type { CompletionItem, TranscriptRow, VirtualHistoryState } from '../app/interfaces.js' +import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' -import type { ActiveTool, ActivityItem, Msg } from '../types.js' import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { AppOverlays } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' import { MessageLine } from './messageLine.js' import { QueuedMessages } from './queuedMessages.js' -import type { PasteEvent } from './textInput.js' import { TextInput } from './textInput.js' import { ToolTrail } from './thinking.js' -export interface AppLayoutActions { - answerApproval: (choice: string) => void - answerClarify: (answer: string) => void - answerSecret: (value: string) => void - answerSudo: (pw: string) => void - onModelSelect: (value: string) => void - resumeById: (id: string) => void - setStickyPrompt: (value: string) => void -} - -export interface AppLayoutComposerProps { - cols: number - compIdx: number - completions: CompletionItem[] - empty: boolean - handleTextPaste: (event: PasteEvent) => { cursor: number; value: string } | null - input: string - inputBuf: string[] - pagerPageSize: number - queueEditIdx: number | null - queuedDisplay: string[] - submit: (value: string) => void - updateInput: (next: string) => void -} - -export interface AppLayoutProgressProps { - activity: ActivityItem[] - reasoning: string - reasoningActive: boolean - reasoningStreaming: boolean - showProgressArea: boolean - showStreamingArea: boolean - streaming: string - tools: ActiveTool[] - turnTrail: string[] -} - -export interface AppLayoutStatusProps { - cwdLabel: string - durationLabel: string - showStickyPrompt: boolean - statusColor: string - stickyPrompt: string - voiceLabel: string -} - -export interface AppLayoutTranscriptProps { - historyItems: Msg[] - scrollRef: RefObject - virtualHistory: VirtualHistoryState - virtualRows: TranscriptRow[] -} - -export interface AppLayoutProps { - actions: AppLayoutActions - composer: AppLayoutComposerProps - mouseTracking: boolean - progress: AppLayoutProgressProps - status: AppLayoutStatusProps - transcript: AppLayoutTranscriptProps -} - export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) { const ui = useStore($uiState) const isBlocked = useStore($isBlocked) @@ -125,8 +60,10 @@ export function AppLayout({ actions, composer, mouseTracking, progress, status, reasoning={progress.reasoning} reasoningActive={progress.reasoningActive} reasoningStreaming={progress.reasoningStreaming} + reasoningTokens={progress.reasoningTokens} t={ui.theme} tools={progress.tools} + toolTokens={progress.toolTokens} trail={progress.turnTrail} /> )} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index e3b646edde..35927f0bdd 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -2,7 +2,7 @@ import { Box, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useGateway } from '../app/gatewayContext.js' -import type { CompletionItem } from '../app/interfaces.js' +import type { AppOverlaysProps } from '../app/interfaces.js' import { $overlayState, patchOverlayState } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' @@ -12,19 +12,6 @@ import { ModelPicker } from './modelPicker.js' import { ApprovalPrompt, ClarifyPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' -export interface AppOverlaysProps { - cols: number - compIdx: number - completions: CompletionItem[] - onApprovalChoice: (choice: string) => void - onClarifyAnswer: (value: string) => void - onModelSelect: (value: string) => void - onPickerSelect: (sessionId: string) => void - onSecretSubmit: (value: string) => void - onSudoSubmit: (pw: string) => void - pagerPageSize: number -} - export function AppOverlays({ cols, compIdx, diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index dbcfeb6071..392b01c49a 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -85,7 +85,14 @@ export const MessageLine = memo(function MessageLine({ > {showDetails && ( - + )} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 04f42ec162..8d75713d01 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,9 +1,10 @@ import { Box, Text } from '@hermes/ink' -import { memo, type ReactNode, useEffect, useState } from 'react' +import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' -import { FACES, VERBS } from '../constants.js' import { + estimateTokensRough, + fmtK, formatToolCall, parseToolTrailResultLine, pick, @@ -89,6 +90,7 @@ function Chevron({ count, onClick, open, + suffix, t, title, tone = 'dim' @@ -96,6 +98,7 @@ function Chevron({ count?: number onClick: () => void open: boolean + suffix?: string t: Theme title: string tone?: 'dim' | 'error' | 'warn' @@ -108,6 +111,12 @@ function Chevron({ {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} + {suffix ? ( + + {' '} + {suffix} + + ) : null} ) @@ -128,29 +137,35 @@ export const Thinking = memo(function Thinking({ streaming?: boolean t: Theme }) { - const [tick, setTick] = useState(0) - - useEffect(() => { - const id = setInterval(() => setTick(v => v + 1), 1100) - - return () => clearInterval(id) - }, []) - const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX) + const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) return ( - - {FACES[tick % FACES.length] ?? '(•_•)'}{' '} - {VERBS[tick % VERBS.length] ?? 'thinking'}… - - {preview ? ( - - - {preview} - - + mode === 'full' ? ( + + + └{' '} + + + {lines.map((line, index) => ( + + {line || ' '} + {index === lines.length - 1 ? ( + + ) : null} + + ))} + + + ) : ( + + + {preview} + + + ) ) : active ? ( @@ -175,9 +190,11 @@ export const ToolTrail = memo(function ToolTrail({ detailsMode = 'collapsed', reasoningActive = false, reasoning = '', + reasoningTokens, reasoningStreaming = false, t, tools = [], + toolTokens, trail = [], activity = [] }: { @@ -185,9 +202,11 @@ export const ToolTrail = memo(function ToolTrail({ detailsMode?: DetailsMode reasoningActive?: boolean reasoning?: string + reasoningTokens?: number reasoningStreaming?: boolean t: Theme tools?: ActiveTool[] + toolTokens?: number trail?: string[] activity?: ActivityItem[] }) { @@ -311,6 +330,17 @@ export const ToolTrail = memo(function ToolTrail({ const hasTools = groups.length > 0 const hasMeta = meta.length > 0 const hasThinking = !!cot || reasoningActive || (busy && !hasTools) + const thinkingLive = reasoningActive || reasoningStreaming + + const tokenCount = reasoningTokens !== undefined ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0 + + const toolTokenCount = toolTokens ?? 0 + const totalTokenCount = tokenCount + toolTokenCount + const thinkingTokensLabel = tokenCount > 0 ? `~${fmtK(tokenCount)} tokens` : null + + const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined + + const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null // ── Hidden: errors/warnings only ────────────────────────────── @@ -368,6 +398,13 @@ export const ToolTrail = memo(function ToolTrail({ )) : null + const totalBlock = totalTokensLabel ? ( + + Σ + {totalTokensLabel} + + ) : null + // ── Expanded: flat, no accordions ────────────────────────────── if (detailsMode === 'expanded') { @@ -376,6 +413,7 @@ export const ToolTrail = memo(function ToolTrail({ {thinkingBlock} {toolBlock} {metaBlock} + {totalBlock} ) } @@ -392,7 +430,20 @@ export const ToolTrail = memo(function ToolTrail({ {hasThinking && ( <> - setOpenThinking(v => !v)} open={openThinking} t={t} title="Thinking" /> + setOpenThinking(v => !v)}> + + {openThinking ? '▾ ' : '▸ '} + + Thinking + + {thinkingTokensLabel ? ( + + {' '} + {thinkingTokensLabel} + + ) : null} + + {openThinking && thinkingBlock} )} @@ -403,6 +454,7 @@ export const ToolTrail = memo(function ToolTrail({ count={groups.length} onClick={() => setOpenTools(v => !v)} open={openTools} + suffix={toolTokensLabel} t={t} title="Tool calls" /> @@ -423,6 +475,8 @@ export const ToolTrail = memo(function ToolTrail({ {openMeta && metaBlock} )} + + {totalBlock} ) }) diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index a35f3c417b..ffa06377b4 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -1,5 +1,6 @@ import { type ChildProcess, spawn } from 'node:child_process' import { EventEmitter } from 'node:events' +import { existsSync } from 'node:fs' import { delimiter, resolve } from 'node:path' import { createInterface } from 'node:readline' @@ -8,6 +9,39 @@ const MAX_LOG_PREVIEW = 240 const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTUP_TIMEOUT_MS ?? '15000', 10) || 15000) const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000) +const resolvePython = (root: string) => { + const configured = process.env.HERMES_PYTHON?.trim() + + if (configured) { + return configured + } + + const envPython = process.env.PYTHON?.trim() + + if (envPython) { + return envPython + } + + const venv = process.env.VIRTUAL_ENV?.trim() + + const candidates = [ + venv ? resolve(venv, 'bin/python') : '', + venv ? resolve(venv, 'Scripts/python.exe') : '', + resolve(root, '.venv/bin/python'), + resolve(root, '.venv/bin/python3'), + resolve(root, 'venv/bin/python'), + resolve(root, 'venv/bin/python3') + ].filter(Boolean) + + const hit = candidates.find(path => existsSync(path)) + + if (hit) { + return hit + } + + return process.platform === 'win32' ? 'python' : 'python3' +} + export interface GatewayEvent { type: string session_id?: string @@ -53,7 +87,7 @@ export class GatewayClient extends EventEmitter { start() { const root = process.env.HERMES_PYTHON_SRC_ROOT ?? resolve(import.meta.dirname, '../../') - const python = process.env.HERMES_PYTHON ?? resolve(root, 'venv/bin/python') + const python = resolvePython(root) const cwd = process.env.HERMES_CWD || root const env = { ...process.env } const pyPath = (env.PYTHONPATH ?? '').trim() diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index b17eff3eea..9d6a9a58e0 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -211,7 +211,7 @@ const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { notation: 'compact' }) -export const fmtK = (n: number) => COMPACT_NUMBER.format(n) +export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) export const hasInterpolation = (s: string) => { INTERPOLATION_RE.lastIndex = 0 diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index aac00d667a..90eef0c630 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -29,6 +29,8 @@ export interface Msg { info?: SessionInfo panelData?: PanelData thinking?: string + thinkingTokens?: number + toolTokens?: number tools?: string[] } From cc15b55bb937206e552056e1cbda28cb8d64276a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 10:23:15 -0500 Subject: [PATCH 110/157] chore: uptick --- hermes_cli/main.py | 23 ++++++---- tests/hermes_cli/test_tui_npm_install.py | 53 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 tests/hermes_cli/test_tui_npm_install.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index d442e138f6..21d544d873 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -725,9 +725,18 @@ def _print_tui_exit_summary(session_id: Optional[str]) -> None: ) -def _tui_deps_ready(root: Path) -> bool: - """Nix and local dev both need file: workspace @hermes/ink under node_modules.""" - return (root / "node_modules" / "@hermes" / "ink" / "package.json").is_file() +def _tui_need_npm_install(root: Path) -> bool: + """True when @hermes/ink is missing or node_modules is behind package-lock.json (post-pull).""" + ink = root / "node_modules" / "@hermes" / "ink" / "package.json" + if not ink.is_file(): + return True + lock = root / "package-lock.json" + if not lock.is_file(): + return False + marker = root / "node_modules" / ".package-lock.json" + if not marker.is_file(): + return True + return lock.stat().st_mtime > marker.stat().st_mtime def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: @@ -735,9 +744,9 @@ def _find_bundled_tui(tui_dir: Path) -> Optional[Path]: env = os.environ.get("HERMES_TUI_DIR") if env: p = Path(env) - if (p / "dist" / "entry.js").exists() and _tui_deps_ready(p): + if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): return p - if (tui_dir / "dist" / "entry.js").exists() and _tui_deps_ready(tui_dir): + if (tui_dir / "dist" / "entry.js").exists() and not _tui_need_npm_install(tui_dir): return tui_dir return None @@ -794,12 +803,12 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: ext_dir = os.environ.get("HERMES_TUI_DIR") if ext_dir: p = Path(ext_dir) - if (p / "dist" / "entry.js").exists() and _tui_deps_ready(p): + if (p / "dist" / "entry.js").exists() and not _tui_need_npm_install(p): node = _node_bin("node") return [node, str(p / "dist" / "entry.js")], p npm = _node_bin("npm") - if not _tui_deps_ready(tui_dir): + if _tui_need_npm_install(tui_dir): print("Installing TUI dependencies…") result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], diff --git a/tests/hermes_cli/test_tui_npm_install.py b/tests/hermes_cli/test_tui_npm_install.py new file mode 100644 index 0000000000..3f3191ccf3 --- /dev/null +++ b/tests/hermes_cli/test_tui_npm_install.py @@ -0,0 +1,53 @@ +"""_tui_need_npm_install: auto npm when lockfile ahead of node_modules.""" + +import os +from pathlib import Path + +import pytest + + +@pytest.fixture +def main_mod(): + import hermes_cli.main as m + + return m + + +def _touch_ink(root: Path) -> None: + ink = root / "node_modules" / "@hermes" / "ink" / "package.json" + ink.parent.mkdir(parents=True, exist_ok=True) + ink.write_text("{}") + + +def test_need_install_when_ink_missing(tmp_path: Path, main_mod) -> None: + (tmp_path / "package-lock.json").write_text("{}") + assert main_mod._tui_need_npm_install(tmp_path) is True + + +def test_need_install_when_lock_newer_than_marker(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text("{}") + (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") + os.utime(tmp_path / "package-lock.json", (200, 200)) + os.utime(tmp_path / "node_modules" / ".package-lock.json", (100, 100)) + assert main_mod._tui_need_npm_install(tmp_path) is True + + +def test_no_install_when_lock_older_than_marker(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text("{}") + (tmp_path / "node_modules" / ".package-lock.json").write_text("{}") + os.utime(tmp_path / "package-lock.json", (100, 100)) + os.utime(tmp_path / "node_modules" / ".package-lock.json", (200, 200)) + assert main_mod._tui_need_npm_install(tmp_path) is False + + +def test_need_install_when_marker_missing(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + (tmp_path / "package-lock.json").write_text("{}") + assert main_mod._tui_need_npm_install(tmp_path) is True + + +def test_no_install_without_lockfile_when_ink_present(tmp_path: Path, main_mod) -> None: + _touch_ink(tmp_path) + assert main_mod._tui_need_npm_install(tmp_path) is False From 9931d1d814a432768ec897d05fba0b24ee1c6991 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 10:35:08 -0500 Subject: [PATCH 111/157] chore: cleanup --- AGENTS.md | 24 +++--- ui-tui/README.md | 103 +++++++++++++++++++------- ui-tui/src/app.tsx | 35 +++++---- ui-tui/src/app/useComposerState.ts | 43 +++++++++-- ui-tui/src/app/useTurnState.ts | 42 +++++++++-- ui-tui/src/components/appOverlays.tsx | 2 +- ui-tui/src/components/branding.tsx | 6 +- ui-tui/src/components/prompts.tsx | 6 +- ui-tui/src/components/thinking.tsx | 8 +- ui-tui/src/gatewayClient.ts | 1 + ui-tui/src/hooks/useCompletion.ts | 34 +++++---- ui-tui/src/hooks/useInputHistory.ts | 6 +- ui-tui/src/hooks/useQueue.ts | 51 ++++++++----- 13 files changed, 250 insertions(+), 111 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6ad6643709..83c32cc800 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,10 @@ hermes-agent/ │ ├── src/entry.tsx # TTY gate + render() │ ├── src/app.tsx # Main state machine and UI │ ├── src/gatewayClient.ts # Child process + JSON-RPC bridge -│ ├── src/components/ # Ink components (branding, markdown, prompts, etc.) -│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue -│ └── src/lib/ # Pure helpers (history, osc52, text) +│ ├── src/app/ # Decomposed app logic (event handler, slash handler, stores, hooks) +│ ├── src/components/ # Ink components (branding, markdown, prompts, pickers, etc.) +│ ├── src/hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory +│ └── src/lib/ # Pure helpers (history, osc52, text, rpc, messages) ├── tui_gateway/ # Python JSON-RPC backend for Ink TUI │ ├── entry.py # stdio entrypoint │ ├── server.py # RPC handlers and session logic @@ -215,7 +216,7 @@ Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. Se | Surface | Ink component | Gateway method | |---------|---------------|----------------| | Chat streaming | `app.tsx` + `messageLine.tsx` | `prompt.submit` → `message.delta/complete` | -| Tool activity | `activityLane.tsx` | `tool.start/progress/complete` | +| Tool activity | `thinking.tsx` | `tool.start/progress/complete` | | Approvals | `prompts.tsx` | `approval.respond` ← `approval.request` | | Clarify/sudo/secret | `prompts.tsx`, `maskedPrompt.tsx` | `clarify/sudo/secret.respond` | | Session picker | `sessionPicker.tsx` | `session.list/resume` | @@ -232,13 +233,14 @@ Newline-delimited JSON-RPC over stdio. Requests from Ink, events from Python. Se ```bash cd ui-tui -npm install # first time -npm run dev # watch mode -npm start # production -npm run build # typecheck -npm run lint # eslint -npm run fmt # prettier -npm test # vitest +npm install # first time +npm run dev # watch mode (rebuilds hermes-ink + tsx --watch) +npm start # production +npm run build # full build (hermes-ink + tsc) +npm run type-check # typecheck only (tsc --noEmit) +npm run lint # eslint +npm run fmt # prettier +npm test # vitest ``` --- diff --git a/ui-tui/README.md b/ui-tui/README.md index b4417d3cc3..38d206baf4 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -59,11 +59,29 @@ npm run fmt npm run fix ``` -There is no package-local test script today. +Tests use vitest: + +```bash +npm test # single run +npm run test:watch +``` ## App model -`src/app.tsx` is the center of the UI. It holds: +`src/app.tsx` is the center of the UI. Heavy logic is split into `src/app/`: + +- `createGatewayEventHandler.ts` — maps gateway events to state updates +- `createSlashHandler.ts` — local slash command dispatch +- `useComposerState.ts` — draft, multiline buffer, queue editing +- `useInputHandlers.ts` — keypress routing +- `useTurnState.ts` — agent turn lifecycle +- `overlayStore.ts` / `uiStore.ts` — nanostores for overlay and UI state +- `gatewayContext.tsx` — React context for the gateway client +- `constants.ts`, `helpers.ts`, `interfaces.ts` + +The top-level `app.tsx` composes these into the Ink tree with `Static` transcript output, a live streaming assistant row, prompt overlays, queue preview, status rule, input line, and completion list. + +State managed at the top level includes: - transcript and streaming state - queued messages and input history @@ -260,30 +278,62 @@ Current color overrides: ## File map ```text -ui-tui/src/ - entry.tsx TTY gate + render() - app.tsx main state machine and UI - gatewayClient.ts child process + JSON-RPC bridge - theme.ts default palette + skin merge - constants.ts display constants, hotkeys, tool labels - types.ts shared client-side types - banner.ts ASCII art data +ui-tui/ + packages/hermes-ink/ forked Ink renderer (local dep) + src/ + entry.tsx TTY gate + render() + app.tsx top-level Ink tree, composes src/app/* + gatewayClient.ts child process + JSON-RPC bridge + theme.ts default palette + skin merge + constants.ts display constants, hotkeys, tool labels + types.ts shared client-side types + banner.ts ASCII art data - components/ - branding.tsx banner + session summary - markdown.tsx Markdown-to-Ink renderer - maskedPrompt.tsx masked input for sudo / secrets - messageLine.tsx transcript rows - prompts.tsx approval + clarify flows - queuedMessages.tsx queued input preview - sessionPicker.tsx session resume picker - textInput.tsx custom line editor - thinking.tsx spinner, reasoning, tool activity + app/ + createGatewayEventHandler.ts event → state mapping + createSlashHandler.ts local slash dispatch + useComposerState.ts draft + multiline + queue editing + useInputHandlers.ts keypress routing + useTurnState.ts agent turn lifecycle + overlayStore.ts nanostores for overlays + uiStore.ts nanostores for UI flags + gatewayContext.tsx React context for gateway client + constants.ts app-level constants + helpers.ts pure helpers + interfaces.ts internal interfaces - lib/ - history.ts persistent input history - osc52.ts OSC 52 clipboard copy - text.ts text helpers, ANSI detection, previews + components/ + appChrome.tsx status bar, input row, completions + appLayout.tsx top-level layout composition + appOverlays.tsx overlay routing (pickers, prompts) + branding.tsx banner + session summary + markdown.tsx Markdown-to-Ink renderer + maskedPrompt.tsx masked input for sudo / secrets + messageLine.tsx transcript rows + modelPicker.tsx model switch picker + prompts.tsx approval + clarify flows + queuedMessages.tsx queued input preview + sessionPicker.tsx session resume picker + textInput.tsx custom line editor + thinking.tsx spinner, reasoning, tool activity + + hooks/ + useCompletion.ts tab completion (slash + path) + useInputHistory.ts persistent history navigation + useQueue.ts queued message management + useVirtualHistory.ts in-memory history for pickers + + lib/ + history.ts persistent input history + messages.ts message formatting helpers + osc52.ts OSC 52 clipboard copy + rpc.ts JSON-RPC type helpers + text.ts text helpers, ANSI detection, previews + + types/ + hermes-ink.d.ts type declarations for @hermes/ink + + __tests__/ vitest suite ``` Related Python side: @@ -293,8 +343,5 @@ tui_gateway/ entry.py stdio entrypoint server.py RPC handlers and session logic render.py optional rich/ANSI bridge + slash_worker.py persistent HermesCLI subprocess for slash commands ``` - -## Notes - -- No dead code: `main.tsx`, `altScreen.tsx`, `commandPalette.tsx`, and `lib/slash.ts` have been removed. diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index d071ba7867..08b4152768 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -173,21 +173,6 @@ export function App({ gw }: { gw: GatewayClient }) { [selection] ) - // ── Resize RPC ─────────────────────────────────────────────────── - - useEffect(() => { - if (!ui.sid || !stdout) { - return - } - - const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) - stdout.on('resize', onResize) - - return () => { - stdout.off('resize', onResize) - } - }, [stdout, ui.sid]) // eslint-disable-line react-hooks/exhaustive-deps - useEffect(() => { const id = setInterval(() => setClockNow(Date.now()), 1000) @@ -256,6 +241,21 @@ export function App({ gw }: { gw: GatewayClient }) { const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + // ── Resize RPC ─────────────────────────────────────────────────── + + useEffect(() => { + if (!ui.sid || !stdout) { + return + } + + const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) + stdout.on('resize', onResize) + + return () => { + stdout.off('resize', onResize) + } + }, [rpc, stdout, ui.sid]) + const answerClarify = useCallback( (answer: string) => { const clarify = overlay.clarify @@ -729,8 +729,8 @@ export function App({ gw }: { gw: GatewayClient }) { return } - composerActions.clearIn() const editIdx = composerRefs.queueEditRef.current + composerActions.clearIn() if (editIdx !== null) { composerActions.replaceQueue(editIdx, full) @@ -769,8 +769,7 @@ export function App({ gw }: { gw: GatewayClient }) { send(full) }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [appendMessage, composerActions, composerRefs] + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] ) // ── Input handling ─────────────────────────────────────────────── diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 8d3df69ee0..467b01614e 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { useStore } from '@nanostores/react' -import { useCallback, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import type { PasteEvent } from '../components/textInput.js' import { useCompletion } from '../hooks/useCompletion.js' @@ -104,8 +104,8 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose } }, [input, inputBuf, submitRef]) - return { - actions: { + const actions = useMemo( + () => ({ clearIn, dequeue, enqueue, @@ -120,15 +120,35 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose setPasteSnips, setQueueEdit, syncQueue - }, - refs: { + }), + [ + clearIn, + dequeue, + enqueue, + handleTextPaste, + openEditor, + pushHistory, + replaceQ, + setCompIdx, + setHistoryIdx, + setQueueEdit, + syncQueue + ] + ) + + const refs = useMemo( + () => ({ historyDraftRef, historyRef, queueEditRef, queueRef, submitRef - }, - state: { + }), + [historyDraftRef, historyRef, queueEditRef, queueRef, submitRef] + ) + + const state = useMemo( + () => ({ compIdx, compReplace, completions, @@ -138,6 +158,13 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose pasteSnips, queueEditIdx, queuedDisplay - } + }), + [compIdx, compReplace, completions, historyIdx, input, inputBuf, pasteSnips, queueEditIdx, queuedDisplay] + ) + + return { + actions, + refs, + state } } diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts index e78b7f489c..d20e252925 100644 --- a/ui-tui/src/app/useTurnState.ts +++ b/ui-tui/src/app/useTurnState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' import type { ActiveTool, ActivityItem } from '../types.js' @@ -191,8 +191,8 @@ export function useTurnState(): UseTurnStateResult { [clearReasoning, idle, streaming] ) - return { - actions: { + const actions = useMemo( + () => ({ clearReasoning, endReasoningPhase, idle, @@ -212,8 +212,23 @@ export function useTurnState(): UseTurnStateResult { setStreaming, setTools, setTurnTrail - }, - refs: { + }), + [ + clearReasoning, + endReasoningPhase, + idle, + interruptTurn, + pruneTransient, + pulseReasoningStreaming, + pushActivity, + pushTrail, + scheduleReasoning, + scheduleStreaming + ] + ) + + const refs = useMemo( + () => ({ activeToolsRef, bufRef, interruptedRef, @@ -228,8 +243,12 @@ export function useTurnState(): UseTurnStateResult { toolTokenAccRef, toolCompleteRibbonRef, turnToolsRef - }, - state: { + }), + [] + ) + + const state = useMemo( + () => ({ activity, reasoning, reasoningTokens, @@ -239,6 +258,13 @@ export function useTurnState(): UseTurnStateResult { streaming, tools, turnTrail - } + }), + [activity, reasoning, reasoningTokens, reasoningActive, toolTokens, reasoningStreaming, streaming, tools, turnTrail] + ) + + return { + actions, + refs, + state } } diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 35927f0bdd..9b7f7b9dbf 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -143,7 +143,7 @@ export function AppOverlays({ diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index d37f86f712..46f6b667fe 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -52,15 +52,17 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string const truncLine = (pfx: string, items: string[]) => { let line = '' + let shown = 0 - for (const item of items.sort()) { + for (const item of [...items].sort()) { const next = line ? `${line}, ${item}` : item if (pfx.length + next.length > lineBudget) { - return line ? `${line}, …+${items.length - line.split(', ').length}` : `${item}, …` + return line ? `${line}, …+${items.length - shown}` : `${item}, …` } line = next + shown++ } return line diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 4e546f3d8b..3dd8a9d756 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -93,7 +93,7 @@ export function ClarifyPrompt({ return } - if (typing) { + if (typing || !choices.length) { return } @@ -117,6 +117,8 @@ export function ClarifyPrompt({ }) if (typing || !choices.length) { + const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel' + return ( {heading} @@ -126,7 +128,7 @@ export function ClarifyPrompt({ - Enter send · Esc back · Ctrl+C cancel + {hint} ) } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 8d75713d01..7d0717c7a1 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -74,10 +74,16 @@ function StreamCursor({ const [on, setOn] = useState(true) useEffect(() => { + if (!visible || !streaming) { + setOn(true) + + return + } + const id = setInterval(() => setOn(v => !v), 420) return () => clearInterval(id) - }, []) + }, [streaming, visible]) return visible ? ( diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index ffa06377b4..caf851220d 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -93,6 +93,7 @@ export class GatewayClient extends EventEmitter { const pyPath = (env.PYTHONPATH ?? '').trim() env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root this.ready = false + this.bufferedEvents = [] this.pendingExit = undefined this.stdoutRl?.close() this.stderrRl?.close() diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index aae1993240..24f9317708 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -1,33 +1,39 @@ import { useEffect, useRef, useState } from 'react' +import type { CompletionItem } from '../app/interfaces.js' import type { GatewayClient } from '../gatewayClient.js' const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ +interface CompletionResult { + items?: CompletionItem[] + replace_from?: number +} + export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { - const [completions, setCompletions] = useState<{ text: string; display: string; meta: string }[]>([]) + const [completions, setCompletions] = useState([]) const [compIdx, setCompIdx] = useState(0) const [compReplace, setCompReplace] = useState(0) const ref = useRef('') useEffect(() => { const clear = () => { - if (!completions.length) { - return - } - - setCompletions([]) - setCompIdx(0) + setCompletions(prev => (prev.length ? [] : prev)) + setCompIdx(prev => (prev ? 0 : prev)) + setCompReplace(prev => (prev ? 0 : prev)) } - if (blocked || input === ref.current) { - if (blocked) { - clear() - } + if (blocked) { + ref.current = '' + clear() return } + if (input === ref.current) { + return + } + ref.current = input const isSlash = input.startsWith('/') @@ -49,7 +55,9 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient : gw.request('complete.path', { word: pathWord }) req - .then((r: any) => { + .then(raw => { + const r = raw as CompletionResult | null | undefined + if (ref.current !== input) { return } @@ -76,7 +84,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient }, 60) return () => clearTimeout(t) - }, [input, blocked, gw]) // eslint-disable-line react-hooks/exhaustive-deps + }, [blocked, gw, input]) return { completions, compIdx, setCompIdx, compReplace } } diff --git a/ui-tui/src/hooks/useInputHistory.ts b/ui-tui/src/hooks/useInputHistory.ts index 0793178fd6..369a9f50f1 100644 --- a/ui-tui/src/hooks/useInputHistory.ts +++ b/ui-tui/src/hooks/useInputHistory.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import * as inputHistory from '../lib/history.js' @@ -7,7 +7,9 @@ export function useInputHistory() { const [historyIdx, setHistoryIdx] = useState(null) const historyDraftRef = useRef('') - const pushHistory = (text: string) => inputHistory.append(text) + const pushHistory = useCallback((text: string) => { + inputHistory.append(text) + }, []) return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } } diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts index c0df224ff0..21bdd51c9e 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useCallback, useRef, useState } from 'react' export function useQueue() { const queueRef = useRef([]) @@ -6,30 +6,47 @@ export function useQueue() { const queueEditRef = useRef(null) const [queueEditIdx, setQueueEditIdx] = useState(null) - const syncQueue = () => setQueuedDisplay([...queueRef.current]) + const syncQueue = useCallback(() => { + setQueuedDisplay([...queueRef.current]) + }, []) - const setQueueEdit = (idx: number | null) => { + const setQueueEdit = useCallback((idx: number | null) => { queueEditRef.current = idx setQueueEditIdx(idx) - } + }, []) - const enqueue = (text: string) => { - queueRef.current.push(text) - syncQueue() - } + const enqueue = useCallback( + (text: string) => { + queueRef.current.push(text) + syncQueue() + }, + [syncQueue] + ) - const dequeue = () => { - const [head, ...rest] = queueRef.current - queueRef.current = rest + const dequeue = useCallback(() => { + const head = queueRef.current.shift() syncQueue() return head - } + }, [syncQueue]) - const replaceQ = (i: number, text: string) => { - queueRef.current[i] = text - syncQueue() - } + const replaceQ = useCallback( + (i: number, text: string) => { + queueRef.current[i] = text + syncQueue() + }, + [syncQueue] + ) - return { queueRef, queueEditRef, queuedDisplay, queueEditIdx, enqueue, dequeue, replaceQ, setQueueEdit, syncQueue } + return { + queueRef, + queueEditRef, + queuedDisplay, + queueEditIdx, + enqueue, + dequeue, + replaceQ, + setQueueEdit, + syncQueue + } } From 4b4b4d47bcbf30a7ca62ab31aa1802a603773a8a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 14:14:01 -0500 Subject: [PATCH 112/157] feat: just more cleaning --- tools/delegate_tool.py | 83 ++- tui_gateway/server.py | 374 +++++++++-- .../createGatewayEventHandler.test.ts | 9 +- ui-tui/src/__tests__/widgets.test.ts | 179 +++++ ui-tui/src/app.tsx | 341 ++++++---- ui-tui/src/app/createGatewayEventHandler.ts | 345 ++++++++-- ui-tui/src/app/createSlashHandler.ts | 212 +++++- ui-tui/src/app/interfaces.ts | 15 +- ui-tui/src/app/useTurnState.ts | 26 +- ui-tui/src/app/widgetStore.ts | 40 ++ ui-tui/src/components/appChrome.tsx | 68 +- ui-tui/src/components/appLayout.tsx | 316 +++++---- ui-tui/src/components/markdown.tsx | 616 +++++++++--------- ui-tui/src/components/modelPicker.tsx | 20 +- ui-tui/src/components/sessionPicker.tsx | 20 +- ui-tui/src/components/sidebarRail.tsx | 15 + ui-tui/src/components/textInput.tsx | 18 + ui-tui/src/components/thinking.tsx | 149 ++++- ui-tui/src/gatewayClient.ts | 30 +- ui-tui/src/gatewayTypes.ts | 198 ++++++ ui-tui/src/hooks/useCompletion.ts | 14 +- ui-tui/src/lib/rpc.ts | 4 +- ui-tui/src/types.ts | 13 + ui-tui/src/widgets.tsx | 576 ++++++++++++++++ 24 files changed, 2852 insertions(+), 829 deletions(-) create mode 100644 ui-tui/src/__tests__/widgets.test.ts create mode 100644 ui-tui/src/app/widgetStore.ts create mode 100644 ui-tui/src/components/sidebarRail.tsx create mode 100644 ui-tui/src/gatewayTypes.ts create mode 100644 ui-tui/src/widgets.tsx diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 73ba81272f..e6b10df89a 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -155,7 +155,7 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: return [t for t in toolsets if t not in blocked_toolset_names] -def _build_child_progress_callback(task_index: int, parent_agent, task_count: int = 1) -> Optional[callable]: +def _build_child_progress_callback(task_index: int, goal: str, parent_agent, task_count: int = 1) -> Optional[callable]: """Build a callback that relays child agent tool calls to the parent display. Two display paths: @@ -173,14 +173,46 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in # Show 1-indexed prefix only in batch mode (multiple tasks) prefix = f"[{task_index + 1}] " if task_count > 1 else "" + goal_label = (goal or "").strip() # Gateway: batch tool names, flush periodically _BATCH_SIZE = 5 _batch: List[str] = [] + def _relay(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs): + if not parent_cb: + return + try: + parent_cb( + event_type, + tool_name, + preview, + args, + task_index=task_index, + task_count=task_count, + goal=goal_label, + **kwargs, + ) + except Exception as e: + logger.debug("Parent callback failed: %s", e) + def _callback(event_type: str, tool_name: str = None, preview: str = None, args=None, **kwargs): # event_type is one of: "tool.started", "tool.completed", - # "reasoning.available", "_thinking", "subagent_progress" + # "reasoning.available", "_thinking", "subagent.*" + + if event_type == "subagent.start": + if spinner and goal_label: + short = (goal_label[:55] + "...") if len(goal_label) > 55 else goal_label + try: + spinner.print_above(f" {prefix}├─ 🔀 {short}") + except Exception as e: + logger.debug("Spinner print_above failed: %s", e) + _relay("subagent.start", preview=preview or goal_label or "", **kwargs) + return + + if event_type == "subagent.complete": + _relay("subagent.complete", preview=preview, **kwargs) + return # "_thinking" / reasoning events if event_type in ("_thinking", "reasoning.available"): @@ -191,7 +223,7 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in spinner.print_above(f" {prefix}├─ 💭 \"{short}\"") except Exception as e: logger.debug("Spinner print_above failed: %s", e) - # Don't relay thinking to gateway (too noisy for chat) + _relay("subagent.thinking", preview=text) return # tool.completed — no display needed here (spinner shows on started) @@ -212,23 +244,18 @@ def _build_child_progress_callback(task_index: int, parent_agent, task_count: in logger.debug("Spinner print_above failed: %s", e) if parent_cb: + _relay("subagent.tool", tool_name, preview, args) _batch.append(tool_name or "") if len(_batch) >= _BATCH_SIZE: summary = ", ".join(_batch) - try: - parent_cb("subagent_progress", f"🔀 {prefix}{summary}") - except Exception as e: - logger.debug("Parent callback failed: %s", e) + _relay("subagent.progress", preview=f"🔀 {prefix}{summary}") _batch.clear() def _flush(): """Flush remaining batched tool names to gateway on completion.""" if parent_cb and _batch: summary = ", ".join(_batch) - try: - parent_cb("subagent_progress", f"🔀 {prefix}{summary}") - except Exception as e: - logger.debug("Parent callback flush failed: %s", e) + _relay("subagent.progress", preview=f"🔀 {prefix}{summary}") _batch.clear() _callback._flush = _flush @@ -242,6 +269,7 @@ def _build_child_agent( toolsets: Optional[List[str]], model: Optional[str], max_iterations: int, + task_count: int, parent_agent, # Credential overrides from delegation config (provider:model resolution) override_provider: Optional[str] = None, @@ -298,7 +326,7 @@ def _build_child_agent( parent_api_key = parent_agent._client_kwargs.get("api_key") # Build progress callback to relay tool calls to parent display - child_progress_cb = _build_child_progress_callback(task_index, parent_agent) + child_progress_cb = _build_child_progress_callback(task_index, goal, parent_agent, task_count) # Each subagent gets its own iteration budget capped at max_iterations # (configurable via delegation.max_iterations, default 50). This means @@ -469,6 +497,12 @@ def _run_single_child( _heartbeat_thread.start() try: + if child_progress_cb: + try: + child_progress_cb("subagent.start", preview=goal) + except Exception as e: + logger.debug("Progress callback start failed: %s", e) + result = child.run_conversation(user_message=goal) # Flush any remaining batched progress to gateway @@ -563,11 +597,34 @@ def _run_single_child( if status == "failed": entry["error"] = result.get("error", "Subagent did not produce a response.") + if child_progress_cb: + try: + child_progress_cb( + "subagent.complete", + preview=summary[:160] if summary else entry.get("error", ""), + status=status, + duration_seconds=duration, + summary=summary[:500] if summary else entry.get("error", ""), + ) + except Exception as e: + logger.debug("Progress callback completion failed: %s", e) + return entry except Exception as exc: duration = round(time.monotonic() - child_start, 2) logging.exception(f"[subagent-{task_index}] failed") + if child_progress_cb: + try: + child_progress_cb( + "subagent.complete", + preview=str(exc), + status="failed", + duration_seconds=duration, + summary=str(exc), + ) + except Exception as e: + logger.debug("Progress callback failure relay failed: %s", e) return { "task_index": task_index, "status": "error", @@ -714,7 +771,7 @@ def delegate_task( child = _build_child_agent( task_index=i, goal=t["goal"], context=t.get("context"), toolsets=t.get("toolsets") or toolsets, model=creds["model"], - max_iterations=effective_max_iter, parent_agent=parent_agent, + max_iterations=effective_max_iter, task_count=n_tasks, parent_agent=parent_agent, override_provider=creds["provider"], override_base_url=creds["base_url"], override_api_key=creds["api_key"], override_api_mode=creds["api_mode"], diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e172cabc27..e3fb585135 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1,6 +1,8 @@ import atexit +import copy import json import os +import queue import subprocess import sys import threading @@ -29,6 +31,10 @@ _pending: dict[str, threading.Event] = {} _answers: dict[str, str] = {} _db = None _stdout_lock = threading.Lock() +_cfg_lock = threading.Lock() +_cfg_cache: dict | None = None +_cfg_mtime: float | None = None +_SLASH_WORKER_TIMEOUT_S = max(5.0, float(os.environ.get("HERMES_TUI_SLASH_TIMEOUT_S", "45") or 45)) # Reserve real stdout for JSON-RPC only; redirect Python's stdout to stderr # so stray print() from libraries/tools becomes harmless gateway.stderr instead @@ -44,6 +50,7 @@ class _SlashWorker: self._lock = threading.Lock() self._seq = 0 self.stderr_tail: list[str] = [] + self.stdout_queue: queue.Queue[dict | None] = queue.Queue() argv = [sys.executable, "-m", "tui_gateway.slash_worker", "--session-key", session_key] if model: @@ -53,8 +60,17 @@ class _SlashWorker: argv, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, cwd=os.getcwd(), env=os.environ.copy(), ) + threading.Thread(target=self._drain_stdout, daemon=True).start() threading.Thread(target=self._drain_stderr, daemon=True).start() + def _drain_stdout(self): + for line in (self.proc.stdout or []): + try: + self.stdout_queue.put(json.loads(line)) + except json.JSONDecodeError: + continue + self.stdout_queue.put(None) + def _drain_stderr(self): for line in (self.proc.stderr or []): if text := line.rstrip("\n"): @@ -70,11 +86,13 @@ class _SlashWorker: self.proc.stdin.write(json.dumps({"id": rid, "command": command}) + "\n") self.proc.stdin.flush() - for line in self.proc.stdout: + while True: try: - msg = json.loads(line) - except json.JSONDecodeError: - continue + msg = self.stdout_queue.get(timeout=_SLASH_WORKER_TIMEOUT_S) + except queue.Empty: + raise RuntimeError("slash worker timed out") + if msg is None: + break if msg.get("id") != rid: continue if not msg.get("ok"): @@ -199,21 +217,58 @@ def _normalize_completion_path(path_part: str) -> str: # ── Config I/O ──────────────────────────────────────────────────────── def _load_cfg() -> dict: + global _cfg_cache, _cfg_mtime try: import yaml p = _hermes_home / "config.yaml" + mtime = p.stat().st_mtime if p.exists() else None + with _cfg_lock: + if _cfg_cache is not None and _cfg_mtime == mtime: + return copy.deepcopy(_cfg_cache) if p.exists(): with open(p) as f: - return yaml.safe_load(f) or {} + data = yaml.safe_load(f) or {} + else: + data = {} + with _cfg_lock: + _cfg_cache = copy.deepcopy(data) + _cfg_mtime = mtime + return data except Exception: pass return {} def _save_cfg(cfg: dict): + global _cfg_cache, _cfg_mtime import yaml - with open(_hermes_home / "config.yaml", "w") as f: + path = _hermes_home / "config.yaml" + with open(path, "w") as f: yaml.safe_dump(cfg, f) + with _cfg_lock: + _cfg_cache = copy.deepcopy(cfg) + try: + _cfg_mtime = path.stat().st_mtime + except Exception: + _cfg_mtime = None + + +def _set_session_context(session_key: str) -> list: + try: + from gateway.session_context import set_session_vars + return set_session_vars(session_key=session_key) + except Exception: + return [] + + +def _clear_session_context(tokens: list) -> None: + if not tokens: + return + try: + from gateway.session_context import clear_session_vars + clear_session_vars(tokens) + except Exception: + pass # ── Blocking prompt factory ────────────────────────────────────────── @@ -307,6 +362,17 @@ def _load_tool_progress_mode() -> str: return mode if mode in {"off", "new", "all", "verbose"} else "all" +def _load_enabled_toolsets() -> list[str] | None: + try: + from hermes_cli.config import load_config + from hermes_cli.tools_config import _get_platform_tools + + enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False)) + return enabled or None + except Exception: + return None + + def _session_show_reasoning(sid: str) -> bool: return bool(_sessions.get(sid, {}).get("show_reasoning", False)) @@ -626,6 +692,27 @@ def _on_tool_progress( return if event_type == "reasoning.available" and preview and _reasoning_visible(sid): _emit("reasoning.available", sid, {"text": str(preview)}) + return + if event_type.startswith("subagent."): + payload = { + "goal": str(_kwargs.get("goal") or ""), + "task_count": int(_kwargs.get("task_count") or 1), + "task_index": int(_kwargs.get("task_index") or 0), + } + if name: + payload["tool_name"] = str(name) + if preview: + payload["text"] = str(preview) + if _kwargs.get("status"): + payload["status"] = str(_kwargs["status"]) + if _kwargs.get("summary"): + payload["summary"] = str(_kwargs["summary"]) + if _kwargs.get("duration_seconds") is not None: + payload["duration_seconds"] = float(_kwargs["duration_seconds"]) + if preview and event_type == "subagent.tool": + payload["tool_preview"] = str(preview) + payload["text"] = str(preview) + _emit(event_type, sid, payload) def _agent_cbs(sid: str) -> dict: @@ -735,14 +822,7 @@ def _apply_personality_to_session(sid: str, session: dict, new_prompt: str) -> t return False, None try: - new_agent = _make_agent(sid, session["session_key"], session_id=session["session_key"]) - session["agent"] = new_agent - with session["history_lock"]: - session["history"] = [] - session["history_version"] = int(session.get("history_version", 0)) + 1 - info = _session_info(new_agent) - _emit("session.info", sid, info) - _restart_slash_worker(session) + info = _reset_session_agent(sid, session) return True, info except Exception: if session.get("agent"): @@ -755,6 +835,61 @@ def _apply_personality_to_session(sid: str, session: dict, new_prompt: str) -> t return False, None +def _background_agent_kwargs(agent, task_id: str) -> dict: + cfg = _load_cfg() + + return { + "base_url": getattr(agent, "base_url", None) or None, + "api_key": getattr(agent, "api_key", None) or None, + "provider": getattr(agent, "provider", None) or None, + "api_mode": getattr(agent, "api_mode", None) or None, + "acp_command": getattr(agent, "acp_command", None) or None, + "acp_args": getattr(agent, "acp_args", None) or None, + "model": getattr(agent, "model", None) or _resolve_model(), + "max_iterations": int(cfg.get("max_turns", 25) or 25), + "enabled_toolsets": getattr(agent, "enabled_toolsets", None) or _load_enabled_toolsets(), + "quiet_mode": True, + "verbose_logging": False, + "ephemeral_system_prompt": getattr(agent, "ephemeral_system_prompt", None) or None, + "providers_allowed": getattr(agent, "providers_allowed", None), + "providers_ignored": getattr(agent, "providers_ignored", None), + "providers_order": getattr(agent, "providers_order", None), + "provider_sort": getattr(agent, "provider_sort", None), + "provider_require_parameters": getattr(agent, "provider_require_parameters", False), + "provider_data_collection": getattr(agent, "provider_data_collection", None), + "session_id": task_id, + "reasoning_config": getattr(agent, "reasoning_config", None) or _load_reasoning_config(), + "service_tier": getattr(agent, "service_tier", None) or _load_service_tier(), + "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), + "platform": "tui", + "session_db": _get_db(), + "fallback_model": getattr(agent, "_fallback_model", None), + } + + +def _reset_session_agent(sid: str, session: dict) -> dict: + tokens = _set_session_context(session["session_key"]) + try: + new_agent = _make_agent(sid, session["session_key"], session_id=session["session_key"]) + finally: + _clear_session_context(tokens) + session["agent"] = new_agent + session["attached_images"] = [] + session["edit_snapshots"] = {} + session["image_counter"] = 0 + session["running"] = False + session["show_reasoning"] = _load_show_reasoning() + session["tool_progress_mode"] = _load_tool_progress_mode() + session["tool_started_at"] = {} + with session["history_lock"]: + session["history"] = [] + session["history_version"] = int(session.get("history_version", 0)) + 1 + info = _session_info(new_agent) + _emit("session.info", sid, info) + _restart_slash_worker(session) + return info + + def _make_agent(sid: str, key: str, session_id: str | None = None): from run_agent import AIAgent cfg = _load_cfg() @@ -767,6 +902,7 @@ def _make_agent(sid: str, key: str, session_id: str | None = None): verbose_logging=_load_tool_progress_mode() == "verbose", reasoning_config=_load_reasoning_config(), service_tier=_load_service_tier(), + enabled_toolsets=_load_enabled_toolsets(), platform="tui", session_id=session_id or key, session_db=_get_db(), ephemeral_system_prompt=system_prompt or None, @@ -857,16 +993,55 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: return text or "What do you see in this image?" +def _history_to_messages(history: list[dict]) -> list[dict]: + messages = [] + tool_call_args = {} + + for m in history: + if not isinstance(m, dict): + continue + role = m.get("role") + if role not in ("user", "assistant", "tool", "system"): + continue + if role == "assistant" and m.get("tool_calls"): + for tc in m["tool_calls"]: + fn = tc.get("function", {}) + tc_id = tc.get("id", "") + if tc_id and fn.get("name"): + try: + args = json.loads(fn.get("arguments", "{}")) + except (json.JSONDecodeError, TypeError): + args = {} + tool_call_args[tc_id] = (fn["name"], args) + if not (m.get("content") or "").strip(): + continue + if role == "tool": + tc_id = m.get("tool_call_id", "") + tc_info = tool_call_args.get(tc_id) if tc_id else None + name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool" + args = (tc_info[1] if tc_info else None) or {} + messages.append({"role": "tool", "name": name, "context": _tool_ctx(name, args)}) + continue + if not (m.get("content") or "").strip(): + continue + messages.append({"role": role, "text": m.get("content") or ""}) + + return messages + + # ── Methods: session ───────────────────────────────────────────────── @method("session.create") def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] key = _new_session_key() - os.environ["HERMES_SESSION_KEY"] = key os.environ["HERMES_INTERACTIVE"] = "1" try: - agent = _make_agent(sid, key) + tokens = _set_session_context(key) + try: + agent = _make_agent(sid, key) + finally: + _clear_session_context(tokens) _get_db().create_session(key, source="tui", model=_resolve_model()) _init_session(sid, key, agent, [], cols=int(params.get("cols", 80))) except Exception as e: @@ -911,41 +1086,16 @@ def _(rid, params: dict) -> dict: else: return _err(rid, 4007, "session not found") sid = uuid.uuid4().hex[:8] - os.environ["HERMES_SESSION_KEY"] = target os.environ["HERMES_INTERACTIVE"] = "1" try: db.reopen_session(target) history = db.get_messages_as_conversation(target) - messages = [] - tool_call_args = {} - for m in history: - role = m.get("role") - if role not in ("user", "assistant", "tool", "system"): - continue - if role == "assistant" and m.get("tool_calls"): - for tc in m["tool_calls"]: - fn = tc.get("function", {}) - tc_id = tc.get("id", "") - if tc_id and fn.get("name"): - try: - args = json.loads(fn.get("arguments", "{}")) - except (json.JSONDecodeError, TypeError): - args = {} - tool_call_args[tc_id] = (fn["name"], args) - if not (m.get("content") or "").strip(): - continue - if role == "tool": - tc_id = m.get("tool_call_id", "") - tc_info = tool_call_args.get(tc_id) if tc_id else None - name = (tc_info[0] if tc_info else None) or m.get("tool_name") or "tool" - args = (tc_info[1] if tc_info else None) or {} - ctx = _tool_ctx(name, args) - messages.append({"role": "tool", "name": name, "context": ctx}) - continue - if not (m.get("content") or "").strip(): - continue - messages.append({"role": role, "text": m.get("content") or ""}) - agent = _make_agent(sid, target, session_id=target) + messages = _history_to_messages(history) + tokens = _set_session_context(target) + try: + agent = _make_agent(sid, target, session_id=target) + finally: + _clear_session_context(tokens) _init_session(sid, target, agent, history, cols=int(params.get("cols", 80))) except Exception as e: return _err(rid, 5000, f"resume failed: {e}") @@ -985,7 +1135,13 @@ def _(rid, params: dict) -> dict: @method("session.history") def _(rid, params: dict) -> dict: session, err = _sess(params, rid) - return err or _ok(rid, {"count": len(session.get("history", []))}) + return err or _ok( + rid, + { + "count": len(session.get("history", [])), + "messages": _history_to_messages(list(session.get("history", []))), + }, + ) @method("session.undo") @@ -1086,9 +1242,12 @@ def _(rid, params: dict) -> dict: except Exception as e: return _err(rid, 5008, f"branch failed: {e}") new_sid = uuid.uuid4().hex[:8] - os.environ["HERMES_SESSION_KEY"] = new_key try: - agent = _make_agent(new_sid, new_key, session_id=new_key) + tokens = _set_session_context(new_key) + try: + agent = _make_agent(new_sid, new_key, session_id=new_key) + finally: + _clear_session_context(tokens) _init_session(new_sid, new_key, agent, list(history), cols=session.get("cols", 80)) except Exception as e: return _err(rid, 5000, f"agent init failed on branch: {e}") @@ -1141,9 +1300,11 @@ def _(rid, params: dict) -> dict: def run(): approval_token = None + session_tokens = [] try: from tools.approval import reset_current_session_key, set_current_session_key approval_token = set_current_session_key(session["session_key"]) + session_tokens = _set_session_context(session["session_key"]) cols = session.get("cols", 80) streamer = make_stream_renderer(cols) prompt = text @@ -1206,6 +1367,7 @@ def _(rid, params: dict) -> dict: reset_current_session_key(approval_token) except Exception: pass + _clear_session_context(session_tokens) with session["history_lock"]: session["running"] = False @@ -1336,14 +1498,19 @@ def _(rid, params: dict) -> dict: task_id = f"bg_{uuid.uuid4().hex[:6]}" def run(): + session_tokens = _set_session_context(task_id) try: from run_agent import AIAgent - result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui", - session_id=task_id, max_iterations=30).run_conversation(text) + result = AIAgent(**_background_agent_kwargs(session["agent"], task_id)).run_conversation( + user_message=text, + task_id=task_id, + ) _emit("background.complete", parent, {"task_id": task_id, "text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)}) except Exception as e: _emit("background.complete", parent, {"task_id": task_id, "text": f"error: {e}"}) + finally: + _clear_session_context(session_tokens) threading.Thread(target=run, daemon=True).start() return _ok(rid, {"task_id": task_id}) @@ -1360,6 +1527,7 @@ def _(rid, params: dict) -> dict: snapshot = list(session.get("history", [])) def run(): + session_tokens = _set_session_context(session["session_key"]) try: from run_agent import AIAgent result = AIAgent(model=_resolve_model(), quiet_mode=True, platform="tui", @@ -1367,6 +1535,8 @@ def _(rid, params: dict) -> dict: _emit("btw.complete", sid, {"text": result.get("final_response", str(result)) if isinstance(result, dict) else str(result)}) except Exception as e: _emit("btw.complete", sid, {"text": f"error: {e}"}) + finally: + _clear_session_context(session_tokens) threading.Thread(target=run, daemon=True).start() return _ok(rid, {"status": "running"}) @@ -1637,8 +1807,8 @@ def _(rid, params: dict) -> dict: @method("process.stop") def _(rid, params: dict) -> dict: try: - from tools.process_registry import ProcessRegistry - return _ok(rid, {"killed": ProcessRegistry().kill_all()}) + from tools.process_registry import process_registry + return _ok(rid, {"killed": process_registry.kill_all()}) except Exception as e: return _err(rid, 5010, str(e)) @@ -2036,8 +2206,8 @@ def _mirror_slash_side_effects(sid: str, session: dict, command: str) -> str: elif name == "reload-mcp" and agent and hasattr(agent, "reload_mcp_tools"): agent.reload_mcp_tools() elif name == "stop": - from tools.process_registry import ProcessRegistry - ProcessRegistry().kill_all() + from tools.process_registry import process_registry + process_registry.kill_all() except Exception as e: return f"live session sync failed: {e}" return "" @@ -2315,9 +2485,7 @@ def _(rid, params: dict) -> dict: try: from toolsets import get_all_toolsets, get_toolset_info session = _sessions.get(params.get("session_id", "")) - enabled = set() - if session: - enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) + enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or []) items = [] for name in sorted(get_all_toolsets().keys()): @@ -2336,14 +2504,92 @@ def _(rid, params: dict) -> dict: return _err(rid, 5031, str(e)) +@method("tools.show") +def _(rid, params: dict) -> dict: + try: + from model_tools import get_toolset_for_tool, get_tool_definitions + + session = _sessions.get(params.get("session_id", "")) + enabled = getattr(session["agent"], "enabled_toolsets", None) if session else _load_enabled_toolsets() + tools = get_tool_definitions(enabled_toolsets=enabled, quiet_mode=True) + sections = {} + + for tool in sorted(tools, key=lambda t: t["function"]["name"]): + name = tool["function"]["name"] + desc = str(tool["function"].get("description", "") or "").split("\n")[0] + if ". " in desc: + desc = desc[:desc.index(". ") + 1] + sections.setdefault(get_toolset_for_tool(name) or "unknown", []).append({ + "name": name, + "description": desc, + }) + + return _ok(rid, { + "sections": [{"name": name, "tools": rows} for name, rows in sorted(sections.items())], + "total": len(tools), + }) + except Exception as e: + return _err(rid, 5034, str(e)) + + +@method("tools.configure") +def _(rid, params: dict) -> dict: + action = str(params.get("action", "") or "").strip().lower() + targets = [str(name).strip() for name in params.get("names", []) or [] if str(name).strip()] + if action not in {"disable", "enable"}: + return _err(rid, 4017, f"unknown tools action: {action}") + if not targets: + return _err(rid, 4018, "names required") + + try: + from hermes_cli.config import load_config, save_config + from hermes_cli.tools_config import ( + CONFIGURABLE_TOOLSETS, + _apply_mcp_change, + _apply_toolset_change, + _get_platform_tools, + _get_plugin_toolset_keys, + ) + + cfg = load_config() + valid_toolsets = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS} | _get_plugin_toolset_keys() + toolset_targets = [name for name in targets if ":" not in name] + mcp_targets = [name for name in targets if ":" in name] + unknown = [name for name in toolset_targets if name not in valid_toolsets] + toolset_targets = [name for name in toolset_targets if name in valid_toolsets] + + if toolset_targets: + _apply_toolset_change(cfg, "cli", toolset_targets, action) + + missing_servers = _apply_mcp_change(cfg, mcp_targets, action) if mcp_targets else set() + save_config(cfg) + + session = _sessions.get(params.get("session_id", "")) + info = _reset_session_agent(params.get("session_id", ""), session) if session else None + enabled = sorted(_get_platform_tools(load_config(), "cli", include_default_mcp_servers=False)) + changed = [ + name for name in targets + if name not in unknown and (":" not in name or name.split(":", 1)[0] not in missing_servers) + ] + + return _ok(rid, { + "changed": changed, + "enabled_toolsets": enabled, + "info": info, + "missing_servers": sorted(missing_servers), + "reset": bool(session), + "unknown": unknown, + }) + except Exception as e: + return _err(rid, 5035, str(e)) + + @method("toolsets.list") def _(rid, params: dict) -> dict: try: from toolsets import get_all_toolsets, get_toolset_info session = _sessions.get(params.get("session_id", "")) - enabled = set() - if session: - enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) + enabled = set(getattr(session["agent"], "enabled_toolsets", []) or []) if session else set(_load_enabled_toolsets() or []) items = [] for name in sorted(get_all_toolsets().keys()): @@ -2364,8 +2610,8 @@ def _(rid, params: dict) -> dict: @method("agents.list") def _(rid, params: dict) -> dict: try: - from tools.process_registry import ProcessRegistry - procs = ProcessRegistry().list_sessions() + from tools.process_registry import process_registry + procs = process_registry.list_sessions() return _ok(rid, { "processes": [{ "session_id": p["session_id"], diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 86489e334a..be27d5347f 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -75,8 +75,7 @@ describe('createGatewayEventHandler', () => { }, transcript: { appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn(), - setMessages: vi.fn() + setHistoryItems: vi.fn() }, turn: { actions: { @@ -191,8 +190,7 @@ describe('createGatewayEventHandler', () => { }, transcript: { appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn(), - setMessages: vi.fn() + setHistoryItems: vi.fn() }, turn: { actions: { @@ -304,8 +302,7 @@ describe('createGatewayEventHandler', () => { }, transcript: { appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn(), - setMessages: vi.fn() + setHistoryItems: vi.fn() }, turn: { actions: { diff --git a/ui-tui/src/__tests__/widgets.test.ts b/ui-tui/src/__tests__/widgets.test.ts new file mode 100644 index 0000000000..39beef9081 --- /dev/null +++ b/ui-tui/src/__tests__/widgets.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_THEME } from '../theme.js' +import type { WidgetSpec } from '../widgets.js' +import { + bloombergTheme, + buildWidgets, + cityTime, + livePoints, + marquee, + plotLineRows, + sparkline, + widgetsInRegion, + wrapWindow +} from '../widgets.js' + +const BASE_CTX = { + bgCount: 0, + busy: false, + cols: 120, + cwdLabel: '~/hermes-agent', + durationLabel: '11s', + model: 'claude', + status: 'idle', + t: DEFAULT_THEME, + usage: { calls: 0, input: 0, output: 0, total: 0 }, + voiceLabel: 'voice off' +} + +describe('sparkline', () => { + it('respects requested width', () => { + expect([...sparkline([1, 2, 3, 4, 5], 3)]).toHaveLength(3) + }) + + it('is stable for flat series', () => { + const line = sparkline([7, 7, 7, 7], 4) + + expect([...line]).toHaveLength(4) + expect(new Set([...line]).size).toBe(1) + }) +}) + +describe('widgetsInRegion', () => { + it('filters and sorts by region + order', () => { + const widgets: WidgetSpec[] = [ + { id: 'c', node: 'c', order: 20, region: 'dock' }, + { id: 'a', node: 'a', order: 5, region: 'dock' }, + { id: 'b', node: 'b', order: 1, region: 'overlay' } + ] + + expect(widgetsInRegion(widgets, 'dock').map(w => w.id)).toEqual(['a', 'c']) + }) +}) + +describe('wrapWindow', () => { + it('wraps around the array', () => { + expect(wrapWindow([1, 2, 3], 2, 5)).toEqual([3, 1, 2, 3, 1]) + }) +}) + +describe('marquee', () => { + it('returns fixed-width slice', () => { + expect(marquee('abc', 0, 5)).toHaveLength(5) + expect(marquee('abc', 1, 5)).not.toEqual(marquee('abc', 0, 5)) + }) +}) + +describe('plotLineRows', () => { + it('returns the requested height', () => { + expect(plotLineRows([1, 2, 3, 4], 4, 3)).toHaveLength(3) + }) + + it('each row has the requested width', () => { + const rows = plotLineRows([1, 4, 2, 5], 20, 4) + + for (const row of rows) { + expect([...row]).toHaveLength(20) + } + }) + + it('draws visible braille for varied data', () => { + expect(plotLineRows([1, 4, 2, 5], 8, 3).join('')).toMatch(/[^\u2800 ]/) + }) +}) + +describe('livePoints', () => { + const asset = { label: 'TEST', series: [100, 102, 101, 103, 105] } + + it('extends the series by one', () => { + expect(livePoints(asset, 0)).toHaveLength(asset.series.length + 1) + }) + + it('starts at series[0]', () => { + expect(livePoints(asset, 0)[0]).toBe(100) + }) + + it('live point stays within ±2% of last value', () => { + const last = asset.series.at(-1)! + + for (let t = 0; t < 50; t++) { + const pts = livePoints(asset, t) + const live = pts.at(-1)! + + expect(live).toBeGreaterThan(last * 0.98) + expect(live).toBeLessThan(last * 1.02) + } + }) +}) + +describe('cityTime', () => { + it('returns HH:MM:SS format', () => { + expect(cityTime('America/New_York')).toMatch(/^\d{2}:\d{2}:\d{2}$/) + }) + + it('works for all clock zones', () => { + for (const tz of ['America/New_York', 'Europe/London', 'Asia/Tokyo', 'Australia/Sydney']) { + expect(cityTime(tz)).toMatch(/^\d{2}:\d{2}:\d{2}$/) + } + }) +}) + +describe('buildWidgets', () => { + it('routes widgets into dock + sidebar regions', () => { + const widgets = buildWidgets({ ...BASE_CTX, blocked: true }) + const byId = new Map(widgets.map(w => [w.id, w.region])) + + expect(byId.get('ticker')).toBe('dock') + expect(byId.get('world-clock')).toBe('sidebar') + expect(byId.get('weather')).toBe('sidebar') + expect(byId.get('heartbeat')).toBe('sidebar') + }) + + it('filters by enabled map', () => { + const enabled = { ticker: true, 'world-clock': false, weather: true, heartbeat: false } + const widgets = buildWidgets(BASE_CTX, { enabled }) + const ids = widgets.map(w => w.id) + + expect(ids).toContain('ticker') + expect(ids).toContain('weather') + expect(ids).not.toContain('world-clock') + expect(ids).not.toContain('heartbeat') + }) + + it('accepts widget params config', () => { + const widgets = buildWidgets(BASE_CTX, { + enabled: { ticker: true, 'world-clock': true, weather: true, heartbeat: true }, + params: { ticker: { asset: 'ETH' } } + }) + + expect(widgets.map(w => w.id)).toContain('ticker') + }) + + it('returns all when no enabled map given', () => { + const widgets = buildWidgets({ ...BASE_CTX, blocked: false }) + + expect(widgets.some(w => w.region === 'overlay')).toBe(false) + expect(widgets.some(w => w.region === 'dock')).toBe(true) + }) + + it('includes all expected widget ids', () => { + const ids = buildWidgets(BASE_CTX).map(w => w.id) + + expect(ids).toContain('ticker') + expect(ids).toContain('weather') + expect(ids).toContain('world-clock') + expect(ids).toContain('heartbeat') + }) +}) + +describe('bloombergTheme', () => { + it('overrides color keys while preserving brand', () => { + const bt = bloombergTheme(DEFAULT_THEME) + + expect(bt.brand).toEqual(DEFAULT_THEME.brand) + expect(bt.color.cornsilk).toBe('#FFFFFF') + expect(bt.color.statusGood).toBe('#00EE00') + expect(bt.color.statusBad).toBe('#FF2200') + }) +}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 08b4152768..98e6149b1d 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -7,7 +7,6 @@ import { createGatewayEventHandler } from './app/createGatewayEventHandler.js' import { createSlashHandler } from './app/createSlashHandler.js' import { GatewayProvider } from './app/gatewayContext.js' import { - fmtDuration, imageTokenMeta, introMsg, looksLikeSlashCommand, @@ -15,7 +14,7 @@ import { shortCwd, toTranscriptMessages } from './app/helpers.js' -import { type TranscriptRow } from './app/interfaces.js' +import { type GatewayRpc, type TranscriptRow } from './app/interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './app/overlayStore.js' import { $uiState, getUiState, patchUiState } from './app/uiStore.js' import { useComposerState } from './app/useComposerState.js' @@ -23,7 +22,8 @@ import { useInputHandlers } from './app/useInputHandlers.js' import { useTurnState } from './app/useTurnState.js' import { AppLayout } from './components/appLayout.js' import { INTERPOLATION_RE, ZERO } from './constants.js' -import { type GatewayClient, type GatewayEvent } from './gatewayClient.js' +import { type GatewayClient } from './gatewayClient.js' +import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from './gatewayTypes.js' import { useVirtualHistory } from './hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js' @@ -60,7 +60,6 @@ export function App({ gw }: { gw: GatewayClient }) { // ── State ──────────────────────────────────────────────────────── - const [messages, setMessages] = useState([]) const [historyItems, setHistoryItems] = useState([]) const [lastUserMsg, setLastUserMsg] = useState('') const [stickyPrompt, setStickyPrompt] = useState('') @@ -70,7 +69,6 @@ export function App({ gw }: { gw: GatewayClient }) { const [voiceProcessing, setVoiceProcessing] = useState(false) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [bellOnComplete, setBellOnComplete] = useState(false) - const [clockNow, setClockNow] = useState(() => Date.now()) const ui = useStore($uiState) const overlay = useStore($overlayState) const isBlocked = useStore($isBlocked) @@ -85,7 +83,13 @@ export function App({ gw }: { gw: GatewayClient }) { const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) const configMtimeRef = useRef(0) + const historyItemsRef = useRef(historyItems) + const lastUserMsgRef = useRef(lastUserMsg) + const msgIdsRef = useRef(new WeakMap()) + const nextMsgIdRef = useRef(0) colsRef.current = cols + historyItemsRef.current = historyItems + lastUserMsgRef.current = lastUserMsg // ── Hooks ──────────────────────────────────────────────────────── @@ -105,17 +109,36 @@ export function App({ gw }: { gw: GatewayClient }) { const composerActions = composer.actions const composerRefs = composer.refs const composerState = composer.state + const composerCompletions = composerState.completions + const composerCompIdx = composerState.compIdx + const composerInput = composerState.input + const composerInputBuf = composerState.inputBuf + const composerQueueEditIdx = composerState.queueEditIdx + const composerQueuedDisplay = composerState.queuedDisplay - const empty = !messages.length + const empty = !historyItems.some(msg => msg.kind !== 'intro') + + const messageId = useCallback((msg: Msg) => { + const hit = msgIdsRef.current.get(msg) + + if (hit) { + return hit + } + + const next = `m${++nextMsgIdRef.current}` + msgIdsRef.current.set(msg, next) + + return next + }, []) const virtualRows = useMemo( () => historyItems.map((msg, index) => ({ index, - key: `${index}:${msg.role}:${msg.kind ?? ''}:${msg.text.slice(0, 40)}`, + key: messageId(msg), msg })), - [historyItems] + [historyItems, messageId] ) const virtualHistory = useVirtualHistory(scrollRef, virtualRows) @@ -173,12 +196,6 @@ export function App({ gw }: { gw: GatewayClient }) { [selection] ) - useEffect(() => { - const id = setInterval(() => setClockNow(Date.now()), 1000) - - return () => clearInterval(id) - }, []) - // ── Core actions ───────────────────────────────────────────────── const appendMessage = useCallback((msg: Msg) => { @@ -189,7 +206,6 @@ export function App({ gw }: { gw: GatewayClient }) { ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY) - setMessages(prev => cap([...prev, msg])) setHistoryItems(prev => cap([...prev, msg])) }, []) @@ -220,10 +236,24 @@ export function App({ gw }: { gw: GatewayClient }) { const pruneTransient = turnActions.pruneTransient const pushTrail = turnActions.pushTrail - const rpc = useCallback( - async (method: string, params: Record = {}) => { + const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { + const display = cfg?.config?.display ?? {} + + setBellOnComplete(!!display?.bell_on_complete) + patchUiState({ + compact: !!display?.tui_compact, + detailsMode: resolveDetailsMode(display), + statusBar: display?.tui_statusbar !== false + }) + }, []) + + const rpc: GatewayRpc = useCallback( + async = Record>( + method: string, + params: Record = {} + ) => { try { - const result = asRpcResult(await gw.request(method, params)) + const result = asRpcResult(await gw.request(method, params)) if (result) { return result @@ -301,20 +331,11 @@ export function App({ gw }: { gw: GatewayClient }) { } rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) - rpc('config.get', { key: 'mtime' }).then((r: any) => { + rpc('config.get', { key: 'mtime' }).then(r => { configMtimeRef.current = Number(r?.mtime ?? 0) }) - rpc('config.get', { key: 'full' }).then((r: any) => { - const display = r?.config?.display ?? {} - - setBellOnComplete(!!display?.bell_on_complete) - patchUiState({ - compact: !!display?.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display?.tui_statusbar !== false - }) - }) - }, [rpc, ui.sid]) + rpc('config.get', { key: 'full' }).then(applyDisplayConfig) + }, [applyDisplayConfig, rpc, ui.sid]) useEffect(() => { if (!ui.sid) { @@ -322,7 +343,7 @@ export function App({ gw }: { gw: GatewayClient }) { } const id = setInterval(() => { - rpc('config.get', { key: 'mtime' }).then((r: any) => { + rpc('config.get', { key: 'mtime' }).then(r => { const next = Number(r?.mtime ?? 0) if (configMtimeRef.current && next && next !== configMtimeRef.current) { @@ -334,16 +355,7 @@ export function App({ gw }: { gw: GatewayClient }) { pushActivity('MCP reloaded after config change') }) - rpc('config.get', { key: 'full' }).then((cfg: any) => { - const display = cfg?.config?.display ?? {} - - setBellOnComplete(!!display?.bell_on_complete) - patchUiState({ - compact: !!display?.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display?.tui_statusbar !== false - }) - }) + rpc('config.get', { key: 'full' }).then(applyDisplayConfig) } else if (!configMtimeRef.current && next) { configMtimeRef.current = next } @@ -351,7 +363,7 @@ export function App({ gw }: { gw: GatewayClient }) { }, 5000) return () => clearInterval(id) - }, [pushActivity, rpc, ui.sid]) + }, [applyDisplayConfig, pushActivity, rpc, ui.sid]) const idle = turnActions.idle const clearReasoning = turnActions.clearReasoning @@ -373,7 +385,7 @@ export function App({ gw }: { gw: GatewayClient }) { usage: ZERO }) setHistoryItems([]) - setMessages([]) + setLastUserMsg('') setStickyPrompt('') composerActions.setPasteSnips([]) turnActions.setActivity([]) @@ -387,7 +399,6 @@ export function App({ gw }: { gw: GatewayClient }) { (info: SessionInfo | null = null) => { idle() clearReasoning() - setMessages([]) setHistoryItems(info ? [introMsg(info)] : []) patchUiState({ info, @@ -403,7 +414,7 @@ export function App({ gw }: { gw: GatewayClient }) { [clearReasoning, composerActions, idle, turnActions, turnRefs] ) - const trimLastExchange = (items: Msg[]) => { + const trimLastExchange = useCallback((items: Msg[]) => { const q = [...items] while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { @@ -415,7 +426,7 @@ export function App({ gw }: { gw: GatewayClient }) { } return q - } + }, []) const guardBusySessionSwitch = useCallback( (what = 'switch sessions') => { @@ -447,7 +458,7 @@ export function App({ gw }: { gw: GatewayClient }) { async (msg?: string) => { await closeSession(getUiState().sid) - return rpc('session.create', { cols: colsRef.current }).then((r: any) => { + return rpc('session.create', { cols: colsRef.current }).then(r => { if (!r) { patchUiState({ status: 'ready' }) @@ -500,7 +511,6 @@ export function App({ gw }: { gw: GatewayClient }) { setSessionStartedAt(Date.now()) const resumed = toTranscriptMessages(r.messages) - setMessages(resumed) setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) patchUiState({ info: r.info ?? null, @@ -833,8 +843,7 @@ export function App({ gw }: { gw: GatewayClient }) { }, transcript: { appendMessage, - setHistoryItems, - setMessages + setHistoryItems }, turn: { actions: { @@ -850,6 +859,7 @@ export function App({ gw }: { gw: GatewayClient }) { setActivity: turnActions.setActivity, setReasoningTokens: turnActions.setReasoningTokens, setStreaming: turnActions.setStreaming, + setSubagents: turnActions.setSubagents, setToolTokens: turnActions.setToolTokens, setTools: turnActions.setTools, setTurnTrail: turnActions.setTurnTrail @@ -913,46 +923,73 @@ export function App({ gw }: { gw: GatewayClient }) { }, [gw, pushActivity, sys]) // ── Slash commands ─────────────────────────────────────────────── - // Always current via ref — no useMemo deps duplication needed. - slashRef.current = createSlashHandler({ - composer: { - enqueue: composerActions.enqueue, - hasSelection, - paste, - queueRef: composerRefs.queueRef, - selection, - setInput: composerActions.setInput - }, - gateway, - local: { + const slash = useMemo( + () => + createSlashHandler({ + composer: { + enqueue: composerActions.enqueue, + hasSelection, + paste, + queueRef: composerRefs.queueRef, + selection, + setInput: composerActions.setInput + }, + gateway, + local: { + catalog, + getHistoryItems: () => historyItemsRef.current, + getLastUserMsg: () => lastUserMsgRef.current, + maybeWarn + }, + session: { + closeSession, + die, + guardBusySessionSwitch, + newSession, + resetVisibleHistory, + resumeById, + setSessionStartedAt + }, + transcript: { + page, + panel, + send, + setHistoryItems, + sys, + trimLastExchange + }, + voice: { + setVoiceEnabled + } + }), + [ catalog, - lastUserMsg, - maybeWarn, - messages - }, - session: { closeSession, + composerActions, + composerRefs, die, + gateway, guardBusySessionSwitch, + hasSelection, + maybeWarn, newSession, - resetVisibleHistory, - resumeById, - setSessionStartedAt - }, - transcript: { page, panel, + paste, + resetVisibleHistory, + resumeById, + selection, send, + setSessionStartedAt, setHistoryItems, - setMessages, + setVoiceEnabled, sys, trimLastExchange - }, - voice: { - setVoiceEnabled - } - }) + ] + ) + + slashRef.current = slash // ── Submit ─────────────────────────────────────────────────────── @@ -1033,7 +1070,7 @@ export function App({ gw }: { gw: GatewayClient }) { ? ui.theme.color.warn : ui.theme.color.dim - const durationLabel = ui.sid ? fmtDuration(clockNow - sessionStartedAt) : '' + const sessionStarted = ui.sid ? sessionStartedAt : null const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) const showStreamingArea = Boolean(turnState.streaming) @@ -1045,7 +1082,12 @@ export function App({ gw }: { gw: GatewayClient }) { ui.detailsMode === 'hidden' ? turnState.activity.some(item => item.tone !== 'info') : Boolean( - ui.busy || turnState.tools.length || turnState.turnTrail.length || hasReasoning || turnState.activity.length + ui.busy || + turnState.subagents.length || + turnState.tools.length || + turnState.turnTrail.length || + hasReasoning || + turnState.activity.length ) const answerApproval = useCallback( @@ -1104,62 +1146,101 @@ export function App({ gw }: { gw: GatewayClient }) { slashRef.current(`/model ${value}`) }, []) + const appActions = useMemo( + () => ({ + answerApproval, + answerClarify, + answerSecret, + answerSudo, + onModelSelect, + resumeById, + setStickyPrompt + }), + [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt] + ) + + const appComposer = useMemo( + () => ({ + cols, + compIdx: composerCompIdx, + completions: composerCompletions, + empty, + handleTextPaste, + input: composerInput, + inputBuf: composerInputBuf, + pagerPageSize, + queueEditIdx: composerQueueEditIdx, + queuedDisplay: composerQueuedDisplay, + submit, + updateInput: composerActions.setInput + }), + [ + cols, + composerActions.setInput, + composerCompIdx, + composerCompletions, + composerInput, + composerInputBuf, + composerQueueEditIdx, + composerQueuedDisplay, + empty, + handleTextPaste, + pagerPageSize, + submit + ] + ) + + const appProgress = useMemo( + () => ({ + activity: turnState.activity, + reasoning: turnState.reasoning, + reasoningActive: turnState.reasoningActive, + reasoningStreaming: turnState.reasoningStreaming, + reasoningTokens: turnState.reasoningTokens, + showProgressArea, + showStreamingArea, + streaming: turnState.streaming, + subagents: turnState.subagents, + toolTokens: turnState.toolTokens, + tools: turnState.tools, + turnTrail: turnState.turnTrail + }), + [showProgressArea, showStreamingArea, turnState] + ) + + const appStatus = useMemo( + () => ({ + cwdLabel, + sessionStartedAt: sessionStarted, + showStickyPrompt, + statusColor, + stickyPrompt, + voiceLabel + }), + [cwdLabel, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] + ) + + const appTranscript = useMemo( + () => ({ + historyItems, + scrollRef, + virtualHistory, + virtualRows + }), + [historyItems, scrollRef, virtualHistory, virtualRows] + ) + // ── Render ─────────────────────────────────────────────────────── return ( ) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 6afd5c094f..86bacdecb6 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,14 +1,16 @@ -import type { GatewayEvent } from '../gatewayClient.js' +import type { CommandsCatalogResponse, GatewayEvent, SessionResumeResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { buildToolTrailLine, estimateTokensRough, + formatToolCall, isToolTrailResultLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' import { fromSkin } from '../theme.js' +import { STREAM_BATCH_MS } from './constants.js' import { introMsg, toTranscriptMessages } from './helpers.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' @@ -19,7 +21,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const { gw, rpc } = ctx.gateway const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session const { bellOnComplete, stdout, sys } = ctx.system - const { appendMessage, setHistoryItems, setMessages } = ctx.transcript + const { appendMessage, setHistoryItems } = ctx.transcript const { clearReasoning, @@ -32,8 +34,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: scheduleReasoning, scheduleStreaming, setActivity, - setReasoningTokens, setStreaming, + setSubagents, setToolTokens, setTools, setTurnTrail @@ -53,6 +55,108 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: turnToolsRef } = ctx.turn.refs + let pendingThinkingStatus = '' + let thinkingStatusTimer: ReturnType | null = null + let toolProgressTimer: ReturnType | null = null + + const cancelThinkingStatus = () => { + pendingThinkingStatus = '' + + if (thinkingStatusTimer) { + clearTimeout(thinkingStatusTimer) + thinkingStatusTimer = null + } + } + + const setStatus = (status: string) => { + cancelThinkingStatus() + patchUiState({ status }) + } + + const scheduleThinkingStatus = (status: string) => { + pendingThinkingStatus = status + + if (thinkingStatusTimer) { + return + } + + thinkingStatusTimer = setTimeout(() => { + thinkingStatusTimer = null + patchUiState({ status: pendingThinkingStatus || (getUiState().busy ? 'running…' : 'ready') }) + }, STREAM_BATCH_MS) + } + + const scheduleToolProgress = () => { + if (toolProgressTimer) { + return + } + + toolProgressTimer = setTimeout(() => { + toolProgressTimer = null + setTools([...activeToolsRef.current]) + }, STREAM_BATCH_MS) + } + + const upsertSubagent = ( + taskIndex: number, + taskCount: number, + goal: string, + update: (current: { + durationSeconds?: number + goal: string + id: string + index: number + notes: string[] + status: 'completed' | 'failed' | 'interrupted' | 'running' + summary?: string + taskCount: number + thinking: string[] + tools: string[] + }) => { + durationSeconds?: number + goal: string + id: string + index: number + notes: string[] + status: 'completed' | 'failed' | 'interrupted' | 'running' + summary?: string + taskCount: number + thinking: string[] + tools: string[] + } + ) => { + const id = `sa:${taskIndex}:${goal || 'subagent'}` + + setSubagents(prev => { + const index = prev.findIndex(item => item.id === id) + + const base = + index >= 0 + ? prev[index]! + : { + id, + index: taskIndex, + taskCount, + goal, + notes: [], + status: 'running' as const, + thinking: [], + tools: [] + } + + const nextItem = update(base) + + if (index < 0) { + return [...prev, nextItem].sort((a, b) => a.index - b.index) + } + + const next = [...prev] + next[index] = nextItem + + return next + }) + } + return (ev: GatewayEvent) => { const sid = getUiState().sid @@ -60,10 +164,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - const p = ev.payload as any - switch (ev.type) { - case 'gateway.ready': + case 'gateway.ready': { + const p = ev.payload + if (p?.skin) { patchUiState({ theme: fromSkin( @@ -75,15 +179,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: }) } - rpc('commands.catalog', {}) - .then((r: any) => { + rpc('commands.catalog', {}) + .then(r => { if (!r?.pairs) { return } setCatalog({ canon: (r.canon ?? {}) as Record, - categories: (r.categories ?? []) as any, + categories: r.categories ?? [], pairs: r.pairs as [string, string][], skillCount: (r.skill_count ?? 0) as number, sub: (r.sub ?? {}) as Record @@ -97,9 +201,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (STARTUP_RESUME_ID) { patchUiState({ status: 'resuming…' }) - gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) - .then((raw: any) => { - const r = asRpcResult(raw) + gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) + .then(raw => { + const r = asRpcResult(raw) if (!r) { throw new Error('invalid response: session.resume') @@ -114,7 +218,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: status: 'ready', usage: r.info?.usage ?? getUiState().usage }) - setMessages(resumed) setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) }) .catch((e: unknown) => { @@ -128,8 +231,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } break + } + + case 'skin.changed': { + const p = ev.payload - case 'skin.changed': if (p) { patchUiState({ theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '') @@ -137,28 +243,36 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } break + } + + case 'session.info': { + const p = ev.payload - case 'session.info': patchUiState(state => ({ ...state, - info: p as any, - usage: p?.usage ? { ...state.usage, ...p.usage } : state.usage + info: p, + usage: p.usage ? { ...state.usage, ...p.usage } : state.usage })) break + } + + case 'thinking.delta': { + const p = ev.payload - case 'thinking.delta': if (p && Object.prototype.hasOwnProperty.call(p, 'text')) { - patchUiState({ status: p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready' }) + scheduleThinkingStatus(p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready') } break + } case 'message.start': patchUiState({ busy: true }) endReasoningPhase() clearReasoning() setActivity([]) + setSubagents([]) setTurnTrail([]) activeToolsRef.current = [] setTools([]) @@ -168,10 +282,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: setToolTokens(0) break + case 'status.update': { + const p = ev.payload - case 'status.update': if (p?.text) { - patchUiState({ status: p.text }) + setStatus(p.text) if (p.kind && p.kind !== 'status') { if (lastStatusNoteRef.current !== p.text) { @@ -194,8 +309,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } break + } + + case 'gateway.stderr': { + const p = ev.payload - case 'gateway.stderr': if (p?.line) { const line = String(p.line).slice(0, 120) const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn' @@ -204,18 +322,24 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } break + } - case 'gateway.start_timeout': - patchUiState({ status: 'gateway startup timeout' }) + case 'gateway.start_timeout': { + const p = ev.payload + + setStatus('gateway startup timeout') pushActivity( `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`, 'error' ) break + } - case 'gateway.protocol_error': - patchUiState({ status: 'protocol warning' }) + case 'gateway.protocol_error': { + const p = ev.payload + + setStatus('protocol warning') if (statusTimerRef.current) { clearTimeout(statusTimerRef.current) @@ -236,17 +360,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } break + } + + case 'reasoning.delta': { + const p = ev.payload - case 'reasoning.delta': if (p?.text) { reasoningRef.current += p.text - setReasoningTokens(estimateTokensRough(reasoningRef.current)) scheduleReasoning() pulseReasoningStreaming() } break + } + case 'reasoning.available': { + const p = ev.payload const incoming = String(p?.text ?? '').trim() if (!incoming) { @@ -261,7 +390,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: // nothing. if (!current) { reasoningRef.current = incoming - setReasoningTokens(estimateTokensRough(reasoningRef.current)) scheduleReasoning() pulseReasoningStreaming() } @@ -269,7 +397,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: break } - case 'tool.progress': + case 'tool.progress': { + const p = ev.payload + if (p?.preview) { const index = activeToolsRef.current.findIndex(tool => tool.name === p.name) @@ -278,28 +408,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: next[index] = { ...next[index]!, context: p.preview as string } activeToolsRef.current = next - setTools(next) + scheduleToolProgress() } } break + } + + case 'tool.generating': { + const p = ev.payload - case 'tool.generating': if (p?.name) { pushTrail(`drafting ${p.name}…`) } break + } + case 'tool.start': { + const p = ev.payload pruneTransient() endReasoningPhase() - const ctx = (p.context as string) || '' + const name = p.name ?? 'tool' + const ctx = p.context ?? '' const sample = `${String(p.name ?? '')} ${ctx}`.trim() toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0 setToolTokens(toolTokenAccRef.current) activeToolsRef.current = [ ...activeToolsRef.current, - { id: p.tool_id, name: p.name, context: ctx, startedAt: Date.now() } + { id: p.tool_id, name, context: ctx, startedAt: Date.now() } ] setTools(activeToolsRef.current) @@ -307,17 +444,13 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } case 'tool.complete': { + const p = ev.payload toolCompleteRibbonRef.current = null const done = activeToolsRef.current.find(tool => tool.id === p.tool_id) - const name = done?.name ?? p.name + const name = done?.name ?? p.name ?? 'tool' const label = toolTrailLabel(name) - const line = buildToolTrailLine( - name, - done?.context || '', - !!p.error, - (p.error as string) || (p.summary as string) || '' - ) + const line = buildToolTrailLine(name, done?.context || '', !!p.error, p.error || p.summary || '') const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line] @@ -333,37 +466,46 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: setTurnTrail(turnToolsRef.current) if (p?.inline_diff) { - sys(p.inline_diff as string) + sys(p.inline_diff) } break } - case 'clarify.request': + case 'clarify.request': { + const p = ev.payload patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } }) - patchUiState({ status: 'waiting for input…' }) + setStatus('waiting for input…') break + } - case 'approval.request': + case 'approval.request': { + const p = ev.payload patchOverlayState({ approval: { command: p.command, description: p.description } }) - patchUiState({ status: 'approval needed' }) + setStatus('approval needed') break + } - case 'sudo.request': + case 'sudo.request': { + const p = ev.payload patchOverlayState({ sudo: { requestId: p.request_id } }) - patchUiState({ status: 'sudo password needed' }) + setStatus('sudo password needed') break + } - case 'secret.request': + case 'secret.request': { + const p = ev.payload patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } }) - patchUiState({ status: 'secret input needed' }) + setStatus('secret input needed') break + } - case 'background.complete': + case 'background.complete': { + const p = ev.payload patchUiState(state => { const next = new Set(state.bgTasks) @@ -374,8 +516,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`[bg ${p.task_id}] ${p.text}`) break + } - case 'btw.complete': + case 'btw.complete': { + const p = ev.payload patchUiState(state => { const next = new Set(state.bgTasks) @@ -386,8 +530,92 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sys(`[btw] ${p.text}`) break + } - case 'message.delta': + case 'subagent.start': { + const p = ev.payload + + upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ + ...current, + goal: p.goal || current.goal, + status: 'running', + taskCount: p.task_count ?? current.taskCount + })) + + break + } + + case 'subagent.thinking': { + const p = ev.payload + const text = String(p.text ?? '').trim() + + if (!text) { + break + } + + upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ + ...current, + goal: p.goal || current.goal, + status: current.status === 'completed' ? current.status : 'running', + taskCount: p.task_count ?? current.taskCount, + thinking: current.thinking.at(-1) === text ? current.thinking : [...current.thinking, text].slice(-6) + })) + + break + } + + case 'subagent.tool': { + const p = ev.payload + const line = formatToolCall(p.tool_name ?? 'delegate_task', p.tool_preview ?? p.text ?? '') + + upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ + ...current, + goal: p.goal || current.goal, + status: current.status === 'completed' ? current.status : 'running', + taskCount: p.task_count ?? current.taskCount, + tools: current.tools.at(-1) === line ? current.tools : [...current.tools, line].slice(-8) + })) + + break + } + + case 'subagent.progress': { + const p = ev.payload + const text = String(p.text ?? '').trim() + + if (!text) { + break + } + + upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ + ...current, + goal: p.goal || current.goal, + status: current.status === 'completed' ? current.status : 'running', + taskCount: p.task_count ?? current.taskCount, + notes: current.notes.at(-1) === text ? current.notes : [...current.notes, text].slice(-6) + })) + + break + } + + case 'subagent.complete': { + const p = ev.payload + const status = p.status ?? 'completed' + + upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ + ...current, + durationSeconds: p.duration_seconds ?? current.durationSeconds, + goal: p.goal || current.goal, + status, + summary: p.summary || p.text || current.summary, + taskCount: p.task_count ?? current.taskCount + })) + + break + } + + case 'message.delta': { + const p = ev.payload pruneTransient() endReasoningPhase() @@ -397,7 +625,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } break + } + case 'message.complete': { + const p = ev.payload const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart() const persisted = persistedToolLabelsRef.current const savedReasoning = reasoningRef.current.trim() @@ -432,7 +663,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: persistedToolLabelsRef.current.clear() setActivity([]) bufRef.current = '' - patchUiState({ status: 'ready' }) + setStatus('ready') if (p?.usage) { patchUiState({ usage: p.usage }) @@ -451,7 +682,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: break } - case 'error': + case 'error': { + const p = ev.payload idle() clearReasoning() turnToolsRef.current = [] @@ -464,9 +696,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: pushActivity(String(p?.message || 'unknown error'), 'error') sys(`error: ${p?.message}`) - patchUiState({ status: 'ready' }) + setStatus('ready') break + } } } } diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index eb8fd7eb5f..55dbac86f2 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,4 +1,12 @@ import { HOTKEYS } from '../constants.js' +import type { + BackgroundStartResponse, + SessionHistoryResponse, + SlashExecResponse, + ToolsConfigureResponse, + ToolsListResponse, + ToolsShowResponse +} from '../gatewayTypes.js' import { writeOsc52Clipboard } from '../lib/osc52.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { fmtK } from '../lib/text.js' @@ -12,7 +20,7 @@ import { getUiState, patchUiState } from './uiStore.js' export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer const { gw, rpc } = ctx.gateway - const { catalog, lastUserMsg, maybeWarn, messages } = ctx.local + const { catalog, getHistoryItems, getLastUserMsg, maybeWarn } = ctx.local const { closeSession, @@ -24,9 +32,24 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b setSessionStartedAt } = ctx.session - const { page, panel, send, setHistoryItems, setMessages, sys, trimLastExchange } = ctx.transcript + const { page, panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript const { setVoiceEnabled } = ctx.voice + const showSlashOutput = (title: string, command: string) => { + gw.request('slash.exec', { command, session_id: getUiState().sid }) + .then(r => { + const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' + const lines = text.split('\n').filter(Boolean) + + if (lines.length > 2 || text.length > 180) { + page(text, title) + } else { + sys(text) + } + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + } + const handler = (cmd: string): boolean => { const ui = getUiState() const detailsMode = ui.detailsMode @@ -160,7 +183,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } - const all = messages.filter((m: any) => m.role === 'assistant') + const all = getHistoryItems().filter((m: any) => m.role === 'assistant') if (arg && Number.isNaN(parseInt(arg, 10))) { sys('usage: /copy [number]') @@ -244,7 +267,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } if (r.removed > 0) { - setMessages((prev: any[]) => trimLastExchange(prev)) setHistoryItems((prev: any[]) => trimLastExchange(prev)) sys(`undid ${r.removed} messages`) } else { @@ -253,8 +275,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b }) return true + case 'retry': { + const lastUserMsg = getLastUserMsg() - case 'retry': if (!lastUserMsg) { sys('nothing to retry') @@ -273,7 +296,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return } - setMessages((prev: any[]) => trimLastExchange(prev)) setHistoryItems((prev: any[]) => trimLastExchange(prev)) send(lastUserMsg) }) @@ -284,6 +306,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b send(lastUserMsg) return true + } case 'background': @@ -294,13 +317,15 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return true } - rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => { - if (!r?.task_id) { + rpc('prompt.background', { session_id: sid, text: arg }).then(r => { + const taskId = r?.task_id + + if (!taskId) { return } - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id) })) - sys(`bg ${r.task_id} started`) + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) })) + sys(`bg ${taskId} started`) }) return true @@ -483,7 +508,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b if (Array.isArray(r.messages)) { const resumed = toTranscriptMessages(r.messages) - setMessages(resumed) setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) } @@ -526,7 +550,6 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b patchUiState({ sid: r.session_id }) setSessionStartedAt(Date.now()) setHistoryItems([]) - setMessages([]) sys(`branched → ${r.title}`) } }) @@ -547,6 +570,26 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return true + case 'fast': + showSlashOutput('Fast', cmd.slice(1)) + + return true + + case 'debug': + showSlashOutput('Debug', cmd.slice(1)) + + return true + + case 'snapshot': + showSlashOutput('Snapshot', cmd.slice(1)) + + return true + + case 'platforms': + showSlashOutput('Platforms', cmd.slice(1)) + + return true + case 'title': rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { if (!r) { @@ -618,12 +661,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return true case 'history': - rpc('session.history', { session_id: sid }).then((r: any) => { + rpc('session.history', { session_id: sid }).then(r => { if (typeof r?.count !== 'number') { return } - sys(`${r.count} messages`) + if (!r.messages?.length) { + sys(`${r.count} messages`) + + return + } + + const text = r.messages + .map((msg, index) => { + if (msg.role === 'tool') { + return `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim() + } + + return `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim() + }) + .join('\n\n') + + page(text, `History (${r.count})`) }) return true @@ -917,29 +976,98 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) return true + case 'tools': { + const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) - case 'tools': - rpc('tools.list', { session_id: sid }) - .then((r: any) => { - if (!r) { - return - } + if (!subcommand) { + rpc('tools.show', { session_id: sid }) + .then(r => { + if (!r?.sections?.length) { + return sys('no tools') + } - if (!r.toolsets?.length) { - return sys('no tools') - } + panel( + `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, + r.sections.map(section => ({ + title: section.name, + rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]) + })) + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - panel( - 'Tools', - r.toolsets.map((ts: any) => ({ - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, - items: ts.tools - })) - ) + return true + } + + if (subcommand === 'list') { + rpc('tools.list', { session_id: sid }) + .then(r => { + if (!r?.toolsets?.length) { + return sys('no tools') + } + + panel( + 'Tools', + r.toolsets.map(ts => ({ + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, + items: ts.tools + })) + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + if (subcommand === 'disable' || subcommand === 'enable') { + if (!names.length) { + sys(`usage: /tools ${subcommand} [name ...]`) + sys(`built-in toolset: /tools ${subcommand} web`) + sys(`MCP tool: /tools ${subcommand} github:create_issue`) + + return true + } + + rpc('tools.configure', { + action: subcommand, + names, + session_id: sid }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .then(r => { + if (!r) { + return + } + + if (r.info) { + setSessionStartedAt(Date.now()) + resetVisibleHistory(r.info) + } + + if (r.changed?.length) { + sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) + } + + if (r.unknown?.length) { + sys(`unknown toolsets: ${r.unknown.join(', ')}`) + } + + if (r.missing_servers?.length) { + sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) + } + + if (r.reset) { + sys('session reset. new tool configuration is active.') + } + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + sys('usage: /tools [list|disable|enable] ...') return true + } case 'toolsets': rpc('toolsets.list', { session_id: sid }) @@ -969,6 +1097,28 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return true default: + if (catalog?.canon) { + const needle = `/${name}`.toLowerCase() + + const matches = [ + ...new Set( + Object.entries(catalog.canon) + .filter(([alias]) => alias.startsWith(needle)) + .map(([, canon]) => canon) + ) + ] + + if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) { + return handler(`${matches[0]}${arg ? ' ' + arg : ''}`) + } + + if (matches.length > 1) { + sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`) + + return true + } + } + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then((r: any) => { sys( diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 549e85fe24..19756e8d35 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -16,6 +16,7 @@ import type { SecretReq, SessionInfo, SlashCatalog, + SubagentProgress, SudoReq, Usage } from '../types.js' @@ -35,7 +36,7 @@ export interface CompletionItem { } export interface GatewayRpc { - (method: string, params?: Record): Promise + (method: string, params?: Record): Promise } export interface GatewayServices { @@ -176,6 +177,7 @@ export interface TurnActions { setToolTokens: StateSetter setReasoningStreaming: StateSetter setStreaming: StateSetter + setSubagents: StateSetter setTools: StateSetter setTurnTrail: StateSetter } @@ -204,6 +206,7 @@ export interface TurnState { reasoningActive: boolean reasoningStreaming: boolean streaming: string + subagents: SubagentProgress[] toolTokens: number tools: ActiveTool[] turnTrail: string[] @@ -278,7 +281,6 @@ export interface GatewayEventHandlerContext { transcript: { appendMessage: (msg: Msg) => void setHistoryItems: StateSetter - setMessages: StateSetter } turn: { actions: Pick< @@ -295,6 +297,7 @@ export interface GatewayEventHandlerContext { | 'setActivity' | 'setReasoningTokens' | 'setStreaming' + | 'setSubagents' | 'setToolTokens' | 'setTools' | 'setTurnTrail' @@ -328,9 +331,9 @@ export interface SlashHandlerContext { gateway: GatewayServices local: { catalog: SlashCatalog | null - lastUserMsg: string + getHistoryItems: () => Msg[] + getLastUserMsg: () => string maybeWarn: (value: any) => void - messages: Msg[] } session: { closeSession: (targetSid?: string | null) => Promise @@ -346,7 +349,6 @@ export interface SlashHandlerContext { panel: (title: string, sections: PanelSection[]) => void send: (text: string) => void setHistoryItems: StateSetter - setMessages: StateSetter sys: (text: string) => void trimLastExchange: (items: Msg[]) => Msg[] } @@ -389,6 +391,7 @@ export interface AppLayoutProgressProps { showProgressArea: boolean showStreamingArea: boolean streaming: string + subagents: SubagentProgress[] toolTokens: number tools: ActiveTool[] turnTrail: string[] @@ -396,7 +399,7 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string - durationLabel: string + sessionStartedAt: number | null showStickyPrompt: boolean statusColor: string stickyPrompt: string diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts index d20e252925..c927773112 100644 --- a/ui-tui/src/app/useTurnState.ts +++ b/ui-tui/src/app/useTurnState.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' -import type { ActiveTool, ActivityItem } from '../types.js' +import { estimateTokensRough, isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' +import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js' import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js' @@ -16,6 +16,7 @@ export function useTurnState(): UseTurnStateResult { const [toolTokens, setToolTokens] = useState(0) const [reasoningStreaming, setReasoningStreaming] = useState(false) const [streaming, setStreaming] = useState('') + const [subagents, setSubagents] = useState([]) const [tools, setTools] = useState([]) const [turnTrail, setTurnTrail] = useState([]) @@ -73,6 +74,7 @@ export function useTurnState(): UseTurnStateResult { reasoningTimerRef.current = setTimeout(() => { reasoningTimerRef.current = null setReasoning(reasoningRef.current) + setReasoningTokens(estimateTokensRough(reasoningRef.current)) }, STREAM_BATCH_MS) }, []) @@ -147,6 +149,7 @@ export function useTurnState(): UseTurnStateResult { const idle = useCallback(() => { endReasoningPhase() activeToolsRef.current = [] + setSubagents([]) setTools([]) setTurnTrail([]) patchUiState({ busy: false }) @@ -165,7 +168,7 @@ export function useTurnState(): UseTurnStateResult { ({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => { interruptedRef.current = true gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - const partial = (streaming || bufRef.current).trimStart() + const partial = bufRef.current.trimStart() if (partial) { appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) @@ -188,7 +191,7 @@ export function useTurnState(): UseTurnStateResult { patchUiState({ status: 'ready' }) }, 1500) }, - [clearReasoning, idle, streaming] + [clearReasoning, idle] ) const actions = useMemo( @@ -210,6 +213,7 @@ export function useTurnState(): UseTurnStateResult { setToolTokens, setReasoningStreaming, setStreaming, + setSubagents, setTools, setTurnTrail }), @@ -256,10 +260,22 @@ export function useTurnState(): UseTurnStateResult { toolTokens, reasoningStreaming, streaming, + subagents, tools, turnTrail }), - [activity, reasoning, reasoningTokens, reasoningActive, toolTokens, reasoningStreaming, streaming, tools, turnTrail] + [ + activity, + reasoning, + reasoningTokens, + reasoningActive, + toolTokens, + reasoningStreaming, + streaming, + subagents, + tools, + turnTrail + ] ) return { diff --git a/ui-tui/src/app/widgetStore.ts b/ui-tui/src/app/widgetStore.ts new file mode 100644 index 0000000000..51fe3e821a --- /dev/null +++ b/ui-tui/src/app/widgetStore.ts @@ -0,0 +1,40 @@ +import { atom } from 'nanostores' + +import { WIDGET_CATALOG } from '../widgets.js' + +export interface WidgetState { + enabled: Record + params: Record> +} + +function defaults(): WidgetState { + const enabled: Record = {} + + for (const w of WIDGET_CATALOG) { + enabled[w.id] = w.defaultOn + } + + return { enabled, params: {} } +} + +export const $widgetState = atom(defaults()) + +export function toggleWidget(id: string, force?: boolean) { + const s = $widgetState.get() + const next = force ?? !s.enabled[id] + + $widgetState.set({ ...s, enabled: { ...s.enabled, [id]: next } }) + + return next +} + +export function setWidgetParam(id: string, key: string, value: string) { + const s = $widgetState.get() + const prev = s.params[id] ?? {} + + $widgetState.set({ ...s, params: { ...s.params, [id]: { ...prev, [key]: value } } }) +} + +export function getWidgetEnabled(id: string): boolean { + return $widgetState.get().enabled[id] ?? false +} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index bb5769f3a9..fff364689e 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,7 +1,7 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react' -import { stickyPromptFromViewport } from '../app/helpers.js' +import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js' import { fmtK } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' @@ -33,6 +33,19 @@ function ctxBar(pct: number | undefined, w = 10) { return '█'.repeat(filled) + '░'.repeat(w - filled) } +function SessionDuration({ startedAt }: { startedAt: number }) { + const [now, setNow] = useState(() => Date.now()) + + useEffect(() => { + setNow(Date.now()) + const id = setInterval(() => setNow(Date.now()), 1000) + + return () => clearInterval(id) + }, [startedAt]) + + return fmtDuration(now - startedAt) +} + export function StatusRule({ cwdLabel, cols, @@ -41,7 +54,7 @@ export function StatusRule({ model, usage, bgCount, - durationLabel, + sessionStartedAt, voiceLabel, t }: { @@ -52,7 +65,7 @@ export function StatusRule({ model: string usage: Usage bgCount: number - durationLabel?: string + sessionStartedAt?: number | null voiceLabel?: string t: Theme }) { @@ -83,7 +96,12 @@ export function StatusRule({ [{bar}] {pctLabel} ) : null} - {durationLabel ? │ {durationLabel} : null} + {sessionStartedAt ? ( + + {' │ '} + + + ) : null} {voiceLabel ? │ {voiceLabel} : null} {bgCount > 0 ? │ {bgCount} bg : null} @@ -177,6 +195,8 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject { if (!s || !scrollable) { @@ -203,25 +223,27 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject setGrab(null)} width={1} > - {Array.from({ length: vp }, (_, i) => { - const active = i >= thumbTop && i < thumbTop + thumb - - const color = active - ? grab !== null - ? t.color.gold - : hover - ? t.color.amber - : t.color.bronze - : hover - ? t.color.bronze - : t.color.dim - - return ( - - {scrollable ? (active ? '┃' : '│') : ' '} - - ) - })} + {!scrollable ? ( + + {' \n'.repeat(Math.max(0, vp - 1))}{' '} + + ) : ( + <> + {thumbTop > 0 ? ( + + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} + + ) : null} + {thumb > 0 ? ( + {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} + ) : null} + {vp - thumbTop - thumb > 0 ? ( + + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} + + ) : null} + + )} ) } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 46bd330c1a..728a8fcce5 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -1,5 +1,6 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' +import { memo } from 'react' import { PLACEHOLDER } from '../app/constants.js' import type { AppLayoutProps } from '../app/interfaces.js' @@ -14,172 +15,203 @@ import { QueuedMessages } from './queuedMessages.js' import { TextInput } from './textInput.js' import { ToolTrail } from './thinking.js' -export function AppLayout({ actions, composer, mouseTracking, progress, status, transcript }: AppLayoutProps) { +const TranscriptPane = memo(function TranscriptPane({ + actions, + composer, + progress, + transcript +}: Pick) { const ui = useStore($uiState) - const isBlocked = useStore($isBlocked) const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end) return ( - - - - - - {transcript.virtualHistory.topSpacer > 0 ? : null} + <> + + + {transcript.virtualHistory.topSpacer > 0 ? : null} - {visibleHistory.map(row => ( - - {row.msg.kind === 'intro' && row.msg.info ? ( - - - - - ) : row.msg.kind === 'panel' && row.msg.panelData ? ( - - ) : ( - - )} + {visibleHistory.map(row => ( + + {row.msg.kind === 'intro' && row.msg.info ? ( + + + - ))} - - {transcript.virtualHistory.bottomSpacer > 0 ? ( - - ) : null} - - {progress.showProgressArea && ( - - )} - - {progress.showStreamingArea && ( + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( + + ) : ( )} - + ))} - - - + {transcript.virtualHistory.bottomSpacer > 0 ? : null} - - - - - - - {ui.bgTasks.size > 0 && ( - - {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running - + {progress.showProgressArea && ( + )} - {status.showStickyPrompt ? ( - - - {status.stickyPrompt} - - ) : ( - - )} - - - {ui.statusBar && ( - - )} - - + )} + + + + + + + + + + ) +}) + +const ComposerPane = memo(function ComposerPane({ + actions, + composer, + status +}: Pick) { + const ui = useStore($uiState) + const isBlocked = useStore($isBlocked) + + return ( + + + + {ui.bgTasks.size > 0 && ( + + {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running + + )} + + {status.showStickyPrompt ? ( + + + {status.stickyPrompt} + + ) : ( + + )} + + + {ui.statusBar && ( + + )} + + + + + {!isBlocked && ( + + {composer.inputBuf.map((line, i) => ( + + + {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} + + + {line || ' '} + + ))} + + + + + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} + + + + + + )} - {!isBlocked && ( - - {composer.inputBuf.map((line, i) => ( - - - {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} - + {!composer.empty && !ui.sid && ⚕ {ui.status}} + + ) +}) - {line || ' '} - - ))} +export const AppLayout = memo(function AppLayout({ + actions, + composer, + mouseTracking, + progress, + status, + transcript +}: AppLayoutProps) { + return ( + + + + + - - - - {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} - - - - - - - )} - - {!composer.empty && !ui.sid && ⚕ {ui.status}} - + ) -} +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 3ba0114abf..865ab85796 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,5 +1,5 @@ import { Box, Text } from '@hermes/ink' -import type { ReactNode } from 'react' +import { memo, type ReactNode, useMemo } from 'react' import type { Theme } from '../theme.js' @@ -212,367 +212,379 @@ function MdInline({ t, text }: { t: Theme; text: string }) { return {parts.length ? parts : {text}} } -export function Md({ compact, t, text }: { compact?: boolean; t: Theme; text: string }) { - const lines = text.split('\n') - const nodes: ReactNode[] = [] - let i = 0 +interface MdProps { + compact?: boolean + t: Theme + text: string +} - let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null +function MdImpl({ compact, t, text }: MdProps) { + const nodes = useMemo(() => { + const lines = text.split('\n') + const nodes: ReactNode[] = [] + let i = 0 - const gap = () => { - if (nodes.length && prevKind !== 'blank') { - nodes.push( ) - prevKind = 'blank' - } - } + let prevKind: 'blank' | 'code' | 'heading' | 'list' | 'paragraph' | 'quote' | 'rule' | 'table' | null = null - const start = (kind: Exclude) => { - if (prevKind && prevKind !== 'blank' && prevKind !== kind) { - gap() + const gap = () => { + if (nodes.length && prevKind !== 'blank') { + nodes.push( ) + prevKind = 'blank' + } } - prevKind = kind - } - - while (i < lines.length) { - const line = lines[i]! - const key = nodes.length - - if (compact && !line.trim()) { - i++ - - continue - } - - if (!line.trim()) { - gap() - i++ - - continue - } - - const fence = parseFence(line) - - if (fence) { - const block: string[] = [] - const lang = fence.lang - - for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) { - block.push(lines[i]!) + const start = (kind: Exclude) => { + if (prevKind && prevKind !== 'blank' && prevKind !== kind) { + gap() } - if (i < lines.length) { + prevKind = kind + } + + while (i < lines.length) { + const line = lines[i]! + const key = nodes.length + + if (compact && !line.trim()) { i++ - } - - if (isMarkdownFence(lang)) { - start('paragraph') - nodes.push() continue } - start('code') + if (!line.trim()) { + gap() + i++ - const isDiff = lang === 'diff' - - nodes.push( - - {lang && !isDiff && {'─ ' + lang}} - {block.map((l, j) => { - const add = isDiff && l.startsWith('+') - const del = isDiff && l.startsWith('-') - const hunk = isDiff && l.startsWith('@@') - - return ( - - {l} - - ) - })} - - ) - - continue - } - - if (line.trim().startsWith('$$')) { - start('code') - - const block: string[] = [] - - for (i++; i < lines.length; i++) { - if (lines[i]!.trim().startsWith('$$')) { - i++ - - break - } - - block.push(lines[i]!) + continue } - nodes.push( - - ─ math - {block.map((l, j) => ( - - {l} - - ))} - - ) + const fence = parseFence(line) - continue - } + if (fence) { + const block: string[] = [] + const lang = fence.lang - const heading = line.match(HEADING_RE) + for (i++; i < lines.length && !isFenceClose(lines[i]!, fence); i++) { + block.push(lines[i]!) + } - if (heading) { - start('heading') - nodes.push( - - {heading[2]} - - ) - i++ + if (i < lines.length) { + i++ + } - continue - } + if (isMarkdownFence(lang)) { + start('paragraph') + nodes.push() - if (i + 1 < lines.length && line.trim()) { - const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/) + continue + } - if (setext) { + start('code') + + const isDiff = lang === 'diff' + + nodes.push( + + {lang && !isDiff && {'─ ' + lang}} + {block.map((l, j) => { + const add = isDiff && l.startsWith('+') + const del = isDiff && l.startsWith('-') + const hunk = isDiff && l.startsWith('@@') + + return ( + + {l} + + ) + })} + + ) + + continue + } + + if (line.trim().startsWith('$$')) { + start('code') + + const block: string[] = [] + + for (i++; i < lines.length; i++) { + if (lines[i]!.trim().startsWith('$$')) { + i++ + + break + } + + block.push(lines[i]!) + } + + nodes.push( + + ─ math + {block.map((l, j) => ( + + {l} + + ))} + + ) + + continue + } + + const heading = line.match(HEADING_RE) + + if (heading) { start('heading') nodes.push( - {line.trim()} + {heading[2]} ) - i += 2 + i++ continue } - } - if (HR_RE.test(line)) { - start('rule') - nodes.push( - - {'─'.repeat(36)} - - ) - i++ + if (i + 1 < lines.length && line.trim()) { + const setext = lines[i + 1]!.match(/^\s{0,3}(=+|-+)\s*$/) - continue - } - - const footnote = line.match(FOOTNOTE_RE) - - if (footnote) { - start('list') - nodes.push( - - [{footnote[1]}] - - ) - i++ - - while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { - nodes.push( - - - + if (setext) { + start('heading') + nodes.push( + + {line.trim()} + ) + i += 2 + + continue + } + } + + if (HR_RE.test(line)) { + start('rule') + nodes.push( + + {'─'.repeat(36)} + + ) + i++ + + continue + } + + const footnote = line.match(FOOTNOTE_RE) + + if (footnote) { + start('list') + nodes.push( + + [{footnote[1]}] + + ) + i++ + + while (i < lines.length && /^\s{2,}\S/.test(lines[i]!)) { + nodes.push( + + + + + + ) + i++ + } + + continue + } + + if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { + start('list') + nodes.push( + + {line.trim()} + + ) + i++ + + while (i < lines.length) { + const def = lines[i]!.match(DEF_RE) + + if (!def) { + break + } + + nodes.push( + + · + + + ) + i++ + } + + continue + } + + const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/) + + if (bullet) { + start('list') + const depth = indentDepth(bullet[1]!) + const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/) + const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' + const body = task ? task[2]! : bullet[2]! + + nodes.push( + + + {' '.repeat(depth * 2)} + {marker}{' '} + + + + ) + i++ + + continue + } + + const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/) + + if (numbered) { + start('list') + const depth = indentDepth(numbered[1]!) + + nodes.push( + + + {' '.repeat(depth * 2)} + {numbered[2]}.{' '} + + + + ) + i++ + + continue + } + + if (/^\s*(?:>\s*)+/.test(line)) { + start('quote') + const quoteLines: Array<{ depth: number; text: string }> = [] + + while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) { + const raw = lines[i]! + const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? '' + + quoteLines.push({ + depth: (prefix.match(/>/g) ?? []).length, + text: raw.slice(prefix.length) + }) + i++ + } + + nodes.push( + + {quoteLines.map((ql, qi) => ( + + {' '.repeat(Math.max(0, ql.depth - 1) * 2)} + {'│ '} + + + ))} ) - i++ + + continue } - continue - } + if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) { + start('table') + const tableRows: string[][] = [] - if (i + 1 < lines.length && DEF_RE.test(lines[i + 1]!)) { - start('list') - nodes.push( - - {line.trim()} - - ) - i++ + tableRows.push(splitTableRow(line)) + i += 2 - while (i < lines.length) { - const def = lines[i]!.match(DEF_RE) - - if (!def) { - break + while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) { + tableRows.push(splitTableRow(lines[i]!)) + i++ } + nodes.push(renderTable(key, tableRows, t)) + + continue + } + + if (/^/i.test(line)) { + i++ + + continue + } + + const summary = line.match(/^(.*?)<\/summary>$/i) + + if (summary) { + start('paragraph') nodes.push( - - · - + + ▶ {summary[1]} ) i++ + + continue } - continue - } - - const bullet = line.match(/^(\s*)[-+*]\s+(.*)$/) - - if (bullet) { - start('list') - const depth = indentDepth(bullet[1]!) - const task = bullet[2]!.match(/^\[( |x|X)\]\s+(.*)$/) - const marker = task ? (task[1]!.toLowerCase() === 'x' ? '☑' : '☐') : '•' - const body = task ? task[2]! : bullet[2]! - - nodes.push( - - - {' '.repeat(depth * 2)} - {marker}{' '} + if (/^<\/?[^>]+>$/.test(line.trim())) { + start('paragraph') + nodes.push( + + {line.trim()} - - - ) - i++ - - continue - } - - const numbered = line.match(/^(\s*)(\d+)[.)]\s+(.*)$/) - - if (numbered) { - start('list') - const depth = indentDepth(numbered[1]!) - - nodes.push( - - - {' '.repeat(depth * 2)} - {numbered[2]}.{' '} - - - - ) - i++ - - continue - } - - if (/^\s*(?:>\s*)+/.test(line)) { - start('quote') - const quoteLines: Array<{ depth: number; text: string }> = [] - - while (i < lines.length && /^\s*(?:>\s*)+/.test(lines[i]!)) { - const raw = lines[i]! - const prefix = raw.match(/^\s*(?:>\s*)+/)?.[0] ?? '' - - quoteLines.push({ - depth: (prefix.match(/>/g) ?? []).length, - text: raw.slice(prefix.length) - }) + ) i++ + + continue } - nodes.push( - - {quoteLines.map((ql, qi) => ( - - {' '.repeat(Math.max(0, ql.depth - 1) * 2)} - {'│ '} - - - ))} - - ) + if (line.includes('|') && line.trim().startsWith('|')) { + start('table') + const tableRows: string[][] = [] - continue - } + while (i < lines.length && lines[i]!.trim().startsWith('|')) { + const row = lines[i]!.trim() - if (line.includes('|') && i + 1 < lines.length && isTableDivider(lines[i + 1]!)) { - start('table') - const tableRows: string[][] = [] + if (!/^[|\s:-]+$/.test(row)) { + tableRows.push(splitTableRow(row)) + } - tableRows.push(splitTableRow(line)) - i += 2 - - while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim()) { - tableRows.push(splitTableRow(lines[i]!)) - i++ - } - - nodes.push(renderTable(key, tableRows, t)) - - continue - } - - if (/^/i.test(line)) { - i++ - - continue - } - - const summary = line.match(/^(.*?)<\/summary>$/i) - - if (summary) { - start('paragraph') - nodes.push( - - ▶ {summary[1]} - - ) - i++ - - continue - } - - if (/^<\/?[^>]+>$/.test(line.trim())) { - start('paragraph') - nodes.push( - - {line.trim()} - - ) - i++ - - continue - } - - if (line.includes('|') && line.trim().startsWith('|')) { - start('table') - const tableRows: string[][] = [] - - while (i < lines.length && lines[i]!.trim().startsWith('|')) { - const row = lines[i]!.trim() - - if (!/^[|\s:-]+$/.test(row)) { - tableRows.push(splitTableRow(row)) + i++ } - i++ + if (tableRows.length) { + nodes.push(renderTable(key, tableRows, t)) + } + + continue } - if (tableRows.length) { - nodes.push(renderTable(key, tableRows, t)) - } + start('paragraph') + nodes.push() - continue + i++ } - start('paragraph') - nodes.push() - - i++ - } + return nodes + }, [compact, t, text]) return {nodes} } + +export const Md = memo(MdImpl) diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index 54e6733f8a..b2891661a9 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink' import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' +import type { ModelOptionProvider, ModelOptionsResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' -interface ProviderItem { - is_current?: boolean - models?: string[] - name: string - slug: string - total_models?: number - warning?: string -} - const VISIBLE = 12 const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) @@ -31,7 +23,7 @@ export function ModelPicker({ sessionId: string | null t: Theme }) { - const [providers, setProviders] = useState([]) + const [providers, setProviders] = useState([]) const [currentModel, setCurrentModel] = useState('') const [err, setErr] = useState('') const [loading, setLoading] = useState(true) @@ -41,9 +33,9 @@ export function ModelPicker({ const [stage, setStage] = useState<'model' | 'provider'>('provider') useEffect(() => { - gw.request('model.options', sessionId ? { session_id: sessionId } : {}) - .then((raw: any) => { - const r = asRpcResult(raw) + gw.request('model.options', sessionId ? { session_id: sessionId } : {}) + .then(raw => { + const r = asRpcResult(raw) if (!r) { setErr('invalid response: model.options') @@ -52,7 +44,7 @@ export function ModelPicker({ return } - const next = (r.providers ?? []) as ProviderItem[] + const next = r.providers ?? [] setProviders(next) setCurrentModel(String(r.model ?? '')) setProviderIdx( diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index b97c6dd7a4..5aeb238782 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -2,18 +2,10 @@ import { Box, Text, useInput } from '@hermes/ink' import { useEffect, useState } from 'react' import type { GatewayClient } from '../gatewayClient.js' +import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' -interface SessionItem { - id: string - title: string - preview: string - started_at: number - message_count: number - source?: string -} - function age(ts: number): string { const d = (Date.now() / 1000 - ts) / 86400 @@ -41,15 +33,15 @@ export function SessionPicker({ onSelect: (id: string) => void t: Theme }) { - const [items, setItems] = useState([]) + const [items, setItems] = useState([]) const [err, setErr] = useState('') const [sel, setSel] = useState(0) const [loading, setLoading] = useState(true) useEffect(() => { - gw.request('session.list', { limit: 20 }) - .then((raw: any) => { - const r = asRpcResult(raw) + gw.request('session.list', { limit: 20 }) + .then(raw => { + const r = asRpcResult(raw) if (!r) { setErr('invalid response: session.list') @@ -58,7 +50,7 @@ export function SessionPicker({ return } - setItems((r?.sessions ?? []) as SessionItem[]) + setItems(r.sessions ?? []) setErr('') setLoading(false) }) diff --git a/ui-tui/src/components/sidebarRail.tsx b/ui-tui/src/components/sidebarRail.tsx new file mode 100644 index 0000000000..80b52078c1 --- /dev/null +++ b/ui-tui/src/components/sidebarRail.tsx @@ -0,0 +1,15 @@ +import { Box, NoSelect } from '@hermes/ink' + +import type { Theme } from '../theme.js' +import type { WidgetSpec } from '../widgets.js' +import { WidgetHost } from '../widgets.js' + +export function SidebarRail({ t, widgets, width }: { t: Theme; widgets: WidgetSpec[]; width: number }) { + return ( + + + + + + ) +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index cfb91b0597..fbbd37ccbe 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -29,8 +29,16 @@ const dim = (s: string) => DIM + s + DIM_OFF let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) +const STOP_CACHE_MAX = 32 +const stopCache = new Map() function graphemeStops(s: string) { + const hit = stopCache.get(s) + + if (hit) { + return hit + } + const stops = [0] for (const { index } of seg().segment(s)) { @@ -43,6 +51,16 @@ function graphemeStops(s: string) { stops.push(s.length) } + stopCache.set(s, stops) + + if (stopCache.size > STOP_CACHE_MAX) { + const oldest = stopCache.keys().next().value + + if (oldest !== undefined) { + stopCache.delete(oldest) + } + } + return stops } diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 7d0717c7a1..afd00e4a2b 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -3,6 +3,7 @@ import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' import { + compactPreview, estimateTokensRough, fmtK, formatToolCall, @@ -13,7 +14,7 @@ import { toolTrailLabel } from '../lib/text.js' import type { Theme } from '../theme.js' -import type { ActiveTool, ActivityItem, DetailsMode, ThinkingMode } from '../types.js' +import type { ActiveTool, ActivityItem, DetailsMode, SubagentProgress, ThinkingMode } from '../types.js' const THINK: BrailleSpinnerName[] = ['helix', 'breathe', 'orbit', 'dna', 'waverows', 'snake', 'pulse'] const TOOL: BrailleSpinnerName[] = ['cascade', 'scan', 'diagswipe', 'fillsweep', 'rain', 'columns', 'sparkle'] @@ -128,6 +129,122 @@ function Chevron({ ) } +function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) { + const [open, setOpen] = useState(expanded) + const [openThinking, setOpenThinking] = useState(expanded) + const [openTools, setOpenTools] = useState(expanded) + const [openNotes, setOpenNotes] = useState(expanded) + + useEffect(() => { + if (!expanded) { + return + } + + setOpen(true) + setOpenThinking(true) + setOpenTools(true) + setOpenNotes(true) + }, [expanded]) + + const statusTone: 'dim' | 'error' | 'warn' = + item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim' + + const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' + const title = `${prefix}${item.goal || `Subagent ${item.index + 1}`}` + const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) + + const suffix = + item.status === 'running' + ? 'running' + : `${item.status}${item.durationSeconds ? ` · ${fmtElapsed(item.durationSeconds * 1000)}` : ''}` + + const thinkingText = item.thinking.join('\n') + const hasThinking = Boolean(thinkingText) + const hasTools = item.tools.length > 0 + const noteRows = [...(summary ? [summary] : []), ...item.notes] + const hasNotes = noteRows.length > 0 + const active = expanded || open + + return ( + + setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} /> + {active && ( + + {hasThinking && ( + <> + setOpenThinking(v => !v)} + open={expanded || openThinking} + t={t} + title="Thinking" + /> + {(expanded || openThinking) && ( + + )} + + )} + + {hasTools && ( + <> + setOpenTools(v => !v)} + open={expanded || openTools} + t={t} + title="Tool calls" + /> + {(expanded || openTools) && ( + + {item.tools.map((line, index) => ( + + + {line} + + ))} + + )} + + )} + + {hasNotes && ( + <> + setOpenNotes(v => !v)} + open={expanded || openNotes} + t={t} + title="Progress" + tone={statusTone} + /> + {(expanded || openNotes) && ( + + {noteRows.map((line, index) => ( + + {index === noteRows.length - 1 ? '└ ' : '├ '} + {line} + + ))} + + )} + + )} + + )} + + ) +} + // ── Thinking ───────────────────────────────────────────────────────── export const Thinking = memo(function Thinking({ @@ -143,7 +260,7 @@ export const Thinking = memo(function Thinking({ streaming?: boolean t: Theme }) { - const preview = thinkingPreview(reasoning, mode, THINKING_COT_MAX) + const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning]) const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) return ( @@ -198,6 +315,7 @@ export const ToolTrail = memo(function ToolTrail({ reasoning = '', reasoningTokens, reasoningStreaming = false, + subagents = [], t, tools = [], toolTokens, @@ -210,6 +328,7 @@ export const ToolTrail = memo(function ToolTrail({ reasoning?: string reasoningTokens?: number reasoningStreaming?: boolean + subagents?: SubagentProgress[] t: Theme tools?: ActiveTool[] toolTokens?: number @@ -219,6 +338,7 @@ export const ToolTrail = memo(function ToolTrail({ const [now, setNow] = useState(() => Date.now()) const [openThinking, setOpenThinking] = useState(false) const [openTools, setOpenTools] = useState(false) + const [openSubagents, setOpenSubagents] = useState(false) const [openMeta, setOpenMeta] = useState(false) useEffect(() => { @@ -235,19 +355,21 @@ export const ToolTrail = memo(function ToolTrail({ if (detailsMode === 'expanded') { setOpenThinking(true) setOpenTools(true) + setOpenSubagents(true) setOpenMeta(true) } if (detailsMode === 'hidden') { setOpenThinking(false) setOpenTools(false) + setOpenSubagents(false) setOpenMeta(false) } }, [detailsMode]) - const cot = thinkingPreview(reasoning, 'full', THINKING_COT_MAX) + const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) - if (!busy && !trail.length && !tools.length && !activity.length && !cot && !reasoningActive) { + if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) { return null } @@ -334,6 +456,7 @@ export const ToolTrail = memo(function ToolTrail({ // ── Derived ──────────────────────────────────────────────────── const hasTools = groups.length > 0 + const hasSubagents = subagents.length > 0 const hasMeta = meta.length > 0 const hasThinking = !!cot || reasoningActive || (busy && !hasTools) const thinkingLive = reasoningActive || reasoningStreaming @@ -395,6 +518,10 @@ export const ToolTrail = memo(function ToolTrail({ )) : null + const subagentBlock = hasSubagents + ? subagents.map(item => ) + : null + const metaBlock = hasMeta ? meta.map((row, i) => ( @@ -418,6 +545,7 @@ export const ToolTrail = memo(function ToolTrail({ {thinkingBlock} {toolBlock} + {subagentBlock} {metaBlock} {totalBlock} @@ -468,6 +596,19 @@ export const ToolTrail = memo(function ToolTrail({ )} + {hasSubagents && ( + <> + setOpenSubagents(v => !v)} + open={openSubagents} + t={t} + title="Subagents" + /> + {openSubagents && subagentBlock} + + )} + {hasMeta && ( <> { return process.platform === 'win32' ? 'python' : 'python3' } -export interface GatewayEvent { - type: string - session_id?: string - payload?: Record +const asGatewayEvent = (value: unknown): GatewayEvent | null => { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return null + } + + return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null } interface Pending { @@ -174,13 +178,23 @@ export class GatewayClient extends EventEmitter { if (p) { this.pending.delete(id!) - msg.error ? p.reject(new Error((msg.error as any).message)) : p.resolve(msg.result) + + if (msg.error) { + const err = msg.error as { message?: unknown } | null | undefined + p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed')) + } else { + p.resolve(msg.result) + } return } if (msg.method === 'event') { - this.publish(msg.params as GatewayEvent) + const ev = asGatewayEvent(msg.params) + + if (ev) { + this.publish(ev) + } } } @@ -218,7 +232,7 @@ export class GatewayClient extends EventEmitter { return this.logs.slice(-Math.max(1, limit)).join('\n') } - request(method: string, params: Record = {}): Promise { + request(method: string, params: Record = {}): Promise { if (!this.proc?.stdin || this.proc.killed || this.proc.exitCode !== null) { this.start() } @@ -243,7 +257,7 @@ export class GatewayClient extends EventEmitter { }, resolve: v => { clearTimeout(timeout) - resolve(v) + resolve(v as T) } }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts new file mode 100644 index 0000000000..2f40b33c9f --- /dev/null +++ b/ui-tui/src/gatewayTypes.ts @@ -0,0 +1,198 @@ +import type { SessionInfo, SlashCategory, Usage } from './types.js' + +export interface GatewaySkin { + banner_hero?: string + banner_logo?: string + branding?: Record + colors?: Record +} + +export interface GatewayCompletionItem { + display: string + meta?: string + text: string +} + +export interface GatewayTranscriptMessage { + context?: string + name?: string + role: 'assistant' | 'system' | 'tool' | 'user' + text?: string +} + +export interface CommandsCatalogResponse { + canon?: Record + categories?: SlashCategory[] + pairs?: [string, string][] + skill_count?: number + sub?: Record + warning?: string +} + +export interface CompletionResponse { + items?: GatewayCompletionItem[] + replace_from?: number +} + +export interface ConfigDisplayConfig { + bell_on_complete?: boolean + details_mode?: string + thinking_mode?: string + tui_compact?: boolean + tui_statusbar?: boolean +} + +export interface ConfigFullResponse { + config?: { + display?: ConfigDisplayConfig + } +} + +export interface ConfigMtimeResponse { + mtime?: number +} + +export interface BackgroundStartResponse { + task_id?: string +} + +export interface SessionCreateResponse { + info?: SessionInfo & { credential_warning?: string } + session_id: string +} + +export interface SessionResumeResponse { + info?: SessionInfo + message_count?: number + messages: GatewayTranscriptMessage[] + resumed?: string + session_id: string +} + +export interface SessionListItem { + id: string + message_count: number + preview: string + source?: string + started_at: number + title: string +} + +export interface SessionListResponse { + sessions?: SessionListItem[] +} + +export interface SessionUndoResponse { + removed?: number +} + +export interface SessionHistoryResponse { + count?: number + messages?: GatewayTranscriptMessage[] +} + +export interface ModelOptionProvider { + is_current?: boolean + models?: string[] + name: string + slug: string + total_models?: number + warning?: string +} + +export interface ModelOptionsResponse { + model?: string + provider?: string + providers?: ModelOptionProvider[] +} + +export interface ToolsetDetails { + description: string + enabled: boolean + name: string + tool_count: number + tools: string[] +} + +export interface ToolsListResponse { + toolsets?: ToolsetDetails[] +} + +export interface ToolSummary { + description: string + name: string +} + +export interface ToolsShowSection { + name: string + tools: ToolSummary[] +} + +export interface ToolsShowResponse { + sections?: ToolsShowSection[] + total?: number +} + +export interface ToolsConfigureResponse { + changed?: string[] + enabled_toolsets?: string[] + info?: SessionInfo + missing_servers?: string[] + reset?: boolean + unknown?: string[] +} + +export interface SlashExecResponse { + output?: string + warning?: string +} + +export interface SubagentEventPayload { + duration_seconds?: number + goal: string + status?: 'completed' | 'failed' | 'interrupted' | 'running' + summary?: string + task_count?: number + task_index: number + text?: string + tool_name?: string + tool_preview?: string +} + +export type GatewayEvent = + | { payload?: { skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' } + | { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' } + | { payload: SessionInfo; session_id?: string; type: 'session.info' } + | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } + | { payload?: undefined; session_id?: string; type: 'message.start' } + | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } + | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } + | { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' } + | { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' } + | { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' } + | { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' } + | { payload: { name?: string }; session_id?: string; type: 'tool.generating' } + | { payload: { context?: string; name?: string; tool_id: string }; session_id?: string; type: 'tool.start' } + | { + payload: { error?: string; inline_diff?: string; name?: string; summary?: string; tool_id: string } + session_id?: string + type: 'tool.complete' + } + | { + payload: { choices: string[] | null; question: string; request_id: string } + session_id?: string + type: 'clarify.request' + } + | { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' } + | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } + | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } + | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } + | { payload: { text: string }; session_id?: string; type: 'btw.complete' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.progress' } + | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' } + | { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' } + | { payload?: { rendered?: string; text?: string; usage?: Usage }; session_id?: string; type: 'message.complete' } + | { payload?: { message?: string }; session_id?: string; type: 'error' } diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 24f9317708..70dbb536f6 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -2,14 +2,10 @@ import { useEffect, useRef, useState } from 'react' import type { CompletionItem } from '../app/interfaces.js' import type { GatewayClient } from '../gatewayClient.js' - +import type { CompletionResponse } from '../gatewayTypes.js' +import { asRpcResult } from '../lib/rpc.js' const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ -interface CompletionResult { - items?: CompletionItem[] - replace_from?: number -} - export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { const [completions, setCompletions] = useState([]) const [compIdx, setCompIdx] = useState(0) @@ -51,12 +47,12 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient } const req = isSlash - ? gw.request('complete.slash', { text: input }) - : gw.request('complete.path', { word: pathWord }) + ? gw.request('complete.slash', { text: input }) + : gw.request('complete.path', { word: pathWord }) req .then(raw => { - const r = raw as CompletionResult | null | undefined + const r = asRpcResult(raw) if (ref.current !== input) { return diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index 502aab8fbf..8bfa7fe201 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -1,11 +1,11 @@ export type RpcResult = Record -export const asRpcResult = (value: unknown): RpcResult | null => { +export const asRpcResult = (value: unknown): T | null => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null } - return value as RpcResult + return value as T } export const rpcErrorMessage = (err: unknown) => { diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 90eef0c630..317d33c971 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -11,6 +11,19 @@ export interface ActivityItem { tone: 'error' | 'info' | 'warn' } +export interface SubagentProgress { + durationSeconds?: number + goal: string + id: string + index: number + notes: string[] + status: 'completed' | 'failed' | 'interrupted' | 'running' + summary?: string + taskCount: number + thinking: string[] + tools: string[] +} + export interface ApprovalReq { command: string description: string diff --git a/ui-tui/src/widgets.tsx b/ui-tui/src/widgets.tsx new file mode 100644 index 0000000000..ffc9a864f8 --- /dev/null +++ b/ui-tui/src/widgets.tsx @@ -0,0 +1,576 @@ +import { Box, Text } from '@hermes/ink' +import { Fragment, type ReactNode, useEffect, useState } from 'react' + +import type { Theme } from './theme.js' +import type { Usage } from './types.js' + +// ── Region types ────────────────────────────────────────────────────── +export const WIDGET_REGIONS = [ + 'transcript-header', + 'transcript-inline', + 'transcript-tail', + 'dock', + 'overlay', + 'sidebar' +] as const +export type WidgetRegion = (typeof WIDGET_REGIONS)[number] + +export interface WidgetCtx { + blocked?: boolean + bgCount: number + busy: boolean + cols: number + cwdLabel?: string + durationLabel?: string + model?: string + status: string + t: Theme + // tick is intentionally NOT here — each widget calls useWidgetTicker() internally. + // Passing tick via props caused useMemo in AppLayout to rebuild JSX on every second, + // which created stale prop snapshots and broke animated text rendering. + usage: Usage + voiceLabel?: string +} + +export interface WidgetSpec { + id: string + node: ReactNode + order?: number + region: WidgetRegion + // Optional: theme transform applied to `t` before rendering. This lets + // individual widgets opt into a different color palette (e.g. Bloomberg) + // without touching the main app theme. + themeOverride?: (base: Theme) => Theme +} + +export interface WidgetRenderState { + enabled?: Record + params?: Record> +} + +// ── Theme overrides ─────────────────────────────────────────────────── +// Bloomberg terminal palette: high-contrast orange/green/red on dark intent. +export function bloombergTheme(t: Theme): Theme { + return { + ...t, + color: { + ...t.color, + gold: '#FFE000', // bright yellow titles + amber: '#FF8C00', // orange for values + bronze: '#FF6600', // orange borders + cornsilk: '#FFFFFF', // white for primary text + dim: '#777777', // gray secondary + label: '#FFCC00', // amber labels + statusGood: '#00EE00', // classic Bloomberg green + statusBad: '#FF2200', // Bloomberg red + statusWarn: '#FFAA00' + } + } +} + +// ── Data ───────────────────────────────────────────────────────────── +interface Asset { + label: string + series: number[] +} + +const BTC: number[] = [ + 83900, 84210, 85140, 84680, 85990, 86540, 87310, 86820, 87940, 88600, 89200, 90100, 90560, 91840, 91210, 92680, 92100, + 91500, 92900, 93200 +] + +const ETH: number[] = [ + 2920, 2960, 3015, 2990, 3070, 3050, 3110, 3080, 3160, 3200, 3225, 3260, 3290, 3250, 3310, 3330, 3385, 3360, 3400, 3420 +] + +const NVDA: number[] = [ + 125, 128, 129, 127, 131, 130, 133, 132, 136, 137, 139, 138, 141, 140, 142, 143, 145, 144, 146, 148 +] + +const TSLA: number[] = [ + 172, 176, 178, 175, 181, 180, 184, 182, 188, 187, 191, 189, 195, 193, 196, 198, 201, 199, 203, 205 +] + +const TEMP_DAY: number[] = [65, 66, 67, 69, 71, 73, 74, 75, 74, 73, 72, 71, 70, 69, 68] + +const USD = new Intl.NumberFormat('en-US', { currency: 'USD', maximumFractionDigits: 0, style: 'currency' }) +const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] +const ORBS = ['◐', '◓', '◑', '◒'] + +const ASSETS: Asset[] = [ + { label: 'BTC', series: BTC }, + { label: 'ETH', series: ETH }, + { label: 'NVDA', series: NVDA }, + { label: 'TSLA', series: TSLA } +] + +const CLOCKS = [ + { label: 'NYC', tz: 'America/New_York' }, + { label: 'LON', tz: 'Europe/London' }, + { label: 'TKY', tz: 'Asia/Tokyo' }, + { label: 'SYD', tz: 'Australia/Sydney' } +] + +const SKY_DESC = ['Overcast', 'Partly cloudy', 'Clear'] +const SKY_ICON = ['☁', '⛅', '☀'] + +// ── Ticker ──────────────────────────────────────────────────────────── +export function useWidgetTicker(ms = 1000) { + const [tick, setTick] = useState(0) + + useEffect(() => { + const id = setInterval(() => setTick(v => v + 1), ms) + + return () => clearInterval(id) + }, [ms]) + + return tick +} + +// ── Pure math helpers ───────────────────────────────────────────────── +// Always resamples to exactly `width` points (no early return for small input). +// The old `if (values.length <= width) return values` caused the braille grid +// to only fill the first N pixel-columns when N < pxWidth. +function sample(values: number[], width: number): number[] { + if (!values.length || width <= 0) { + return [] + } + + if (width === 1) { + return [values.at(-1) ?? 0] + } + + return Array.from({ length: width }, (_, i) => { + const pos = (i * (values.length - 1)) / (width - 1) + + return values[Math.round(pos)] ?? values.at(-1) ?? 0 + }) +} + +export function sparkline(values: number[], width: number): string { + const pts = sample(values, width) + + if (!pts.length) { + return '' + } + + const lo = Math.min(...pts) + const hi = Math.max(...pts) + + if (lo === hi) { + return BARS[Math.floor(BARS.length / 2)]!.repeat(pts.length) + } + + return pts + .map(v => BARS[Math.max(0, Math.min(BARS.length - 1, Math.round(((v - lo) / (hi - lo)) * (BARS.length - 1))))]!) + .join('') +} + +export function wrapWindow(values: T[], offset: number, width: number): T[] { + if (!values.length || width <= 0) { + return [] + } + + const start = ((offset % values.length) + values.length) % values.length + + return Array.from({ length: width }, (_, i) => values[(start + i) % values.length]!) +} + +export function marquee(text: string, offset: number, width: number): string { + if (!text || width <= 0) { + return '' + } + + const gap = ' ' + const source = text + gap + text + const span = text.length + gap.length + const start = ((offset % span) + span) % span + + return source.slice(start, start + width).padEnd(width, ' ') +} + +// ── Braille line chart ──────────────────────────────────────────────── +function brailleBit(dx: number, dy: number): number { + return dx === 0 ? ([0x1, 0x2, 0x4, 0x40][dy] ?? 0) : ([0x8, 0x10, 0x20, 0x80][dy] ?? 0) +} + +function drawLine(grid: boolean[][], x0: number, y0: number, x1: number, y1: number) { + let x = x0 + let y = y0 + const dx = Math.abs(x1 - x0) + const sx = x0 < x1 ? 1 : -1 + const dy = -Math.abs(y1 - y0) + const sy = y0 < y1 ? 1 : -1 + let err = dx + dy + + while (true) { + if (grid[y] && x >= 0 && x < grid[y]!.length) { + grid[y]![x] = true + } + + if (x === x1 && y === y1) { + return + } + + const e2 = err * 2 + + if (e2 >= dy) { + err += dy + x += sx + } + + if (e2 <= dx) { + err += dx + y += sy + } + } +} + +export function plotLineRows(values: number[], width: number, height: number): string[] { + if (!values.length || width <= 0 || height <= 0) { + return [] + } + + const pxW = Math.max(2, width * 2) + const pxH = Math.max(4, height * 4) + const pts = sample(values, pxW) + const lo = Math.min(...pts) + const hi = Math.max(...pts) + const grid = Array.from({ length: pxH }, () => new Array(pxW).fill(false)) + + const yFor = (v: number) => (hi === lo ? Math.floor((pxH - 1) / 2) : Math.round(((hi - v) / (hi - lo)) * (pxH - 1))) + + let prevY = yFor(pts[0] ?? 0) + + if (grid[prevY]) { + grid[prevY]![0] = true + } + + for (let x = 1; x < pts.length; x++) { + const y = yFor(pts[x] ?? pts[x - 1] ?? 0) + drawLine(grid, x - 1, prevY, x, y) + prevY = y + } + + return Array.from({ length: height }, (_, row) => { + const top = row * 4 + + return Array.from({ length: width }, (_, col) => { + const left = col * 2 + let bits = 0 + + for (let dy = 0; dy < 4; dy++) { + for (let dx = 0; dx < 2; dx++) { + if (grid[top + dy]?.[left + dx]) { + bits |= brailleBit(dx, dy) + } + } + } + + return String.fromCodePoint(0x2800 + bits) + }).join('') + }) +} + +// ── Domain helpers ──────────────────────────────────────────────────── +function pct(now: number, start: number): number { + return !start ? 0 : ((now - start) / start) * 100 +} + +function deltaColor(delta: number, t: Theme): string { + return delta > 0 ? t.color.statusGood : delta < 0 ? t.color.statusBad : t.color.dim +} + +function money(v: number): string { + return USD.format(v) +} + +function changeStr(v: number): string { + return `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` +} + +export function cityTime(tz: string): string { + try { + return new Date().toLocaleTimeString('en-US', { + hour: '2-digit', + hour12: false, + minute: '2-digit', + second: '2-digit', + timeZone: tz + }) + } catch { + return '--:--:--' + } +} + +function fToC(f: number): number { + return Math.round(((f - 32) * 5) / 9) +} + +// Smooth live points — always starts at series[0], adds an animated live +// endpoint oscillating ±0.8% so the chart never has discontinuous jumps. +export function livePoints(asset: Asset, tick: number): number[] { + const last = asset.series.at(-1) ?? 0 + const phase = (tick * 0.3) % (Math.PI * 2) + const live = Math.round(last * (1 + Math.sin(phase) * 0.008)) + + return [...asset.series, live] +} + +// ── Primitive components ────────────────────────────────────────────── +function LineChart({ color, height, values, width }: { color: any; height: number; values: number[]; width: number }) { + const rows = plotLineRows(values, width, height) + + return ( + + {rows.map((row, i) => ( + + {row} + + ))} + + ) +} + +// Simple widget frame system: +// - bordered widgets: default card chrome +// - bleed widgets: full-surface background with internal padding +function WidgetFrame({ + backgroundColor, + bordered = true, + borderColor, + children, + paddingX = 1, + paddingY = 0, + title, + titleRight, + titleTone, + t +}: { + backgroundColor?: any + bordered?: boolean + borderColor?: any + children: ReactNode + paddingX?: number + paddingY?: number + title: ReactNode + titleRight?: ReactNode + titleTone?: any + t: Theme +}) { + return ( + + + + {typeof title === 'string' || typeof title === 'number' ? ( + + {title} + + ) : ( + title + )} + + {titleRight ? ( + + {typeof titleRight === 'string' || typeof titleRight === 'number' ? ( + {titleRight} + ) : ( + titleRight + )} + + ) : null} + + {children} + + ) +} + +// ── Widgets ─────────────────────────────────────────────────────────── + +// Bloomberg-styled hero chart. +// Dark navy background for the chart cell so the green/red line pops. +// Custom layout (not Card) for full control over the title row structure. +// Compact single-line ticker strip for the dock region. +function TickerStrip({ cols, t, assetId }: WidgetCtx & { assetId?: string }) { + const tick = useWidgetTicker(1200) + + const asset = ASSETS.find(a => a.label.toLowerCase() === assetId?.toLowerCase()) ?? ASSETS[tick % ASSETS.length]! + + const pts = livePoints(asset, tick) + const last = pts.at(-1) ?? 0 + const first = pts[0] ?? 0 + const change = pct(last, first) + const color = deltaColor(change, t) + const sparkW = Math.max(8, Math.min(30, cols - 40)) + + const others = ASSETS.filter(a => a.label !== asset.label) + .map(a => { + const c = pct(a.series.at(-1) ?? 0, a.series[0] ?? 0) + + return `${a.label} ${changeStr(c)}` + }) + .join(' ') + + return ( + + + {asset.label} + + {` ${money(last)} `} + + {changeStr(change)} + + {` ${sparkline(pts, sparkW)} `} + {others} + + ) +} + +function WeatherCard({ t }: WidgetCtx) { + const tick = useWidgetTicker(2000) + const skyIdx = Math.floor(tick / 8) % SKY_DESC.length + const desc = SKY_DESC[skyIdx]! + const icon = SKY_ICON[skyIdx]! + const temp = TEMP_DAY[tick % TEMP_DAY.length]! + const wind = 9 + ((tick * 2) % 7) + const hum = 64 + (tick % 8) + + return ( + + + Weather + + {`${icon} ${fToC(temp)}C · ${desc}`} + {`Wind ${wind} km/h · Humidity ${hum}%`} + + ) +} + +// 2x2 clock grid in compact rows +function WorldClock({ cols, t }: WidgetCtx) { + const tick = useWidgetTicker() + const orb = ORBS[tick % ORBS.length]! + const rows = [CLOCKS.slice(0, 2), CLOCKS.slice(2, 4)] as const + const slotW = Math.max(12, Math.floor((Math.max(cols, 24) - 2) / 2)) + const cell = (label: string, tz: string) => `${label} ${cityTime(tz)}` + + return ( + + {`${orb} World Clock`} + {rows.map((row, i) => ( + + + + {cell(row[0]!.label, row[0]!.tz)} + + + + + {cell(row[1]!.label, row[1]!.tz)} + + + + ))} + + ) +} + +function HeartBeat({ t }: WidgetCtx) { + const tick = useWidgetTicker(700) + const bpm = 70 + (tick % 5) + + return ( + + + Heartbeat + + {`❤️ ${bpm} bpm`} + + ) +} + +// ── Widget catalog ──────────────────────────────────────────────────── +export interface WidgetDef { + id: string + description: string + region: WidgetRegion + order: number + defaultOn: boolean + params?: string[] +} + +export const WIDGET_CATALOG: WidgetDef[] = [ + { + id: 'ticker', + description: 'Live stock ticker strip', + region: 'dock', + order: 10, + defaultOn: true, + params: ['asset'] + }, + { id: 'world-clock', description: '2x2 world clock grid', region: 'sidebar', order: 10, defaultOn: true }, + { id: 'weather', description: 'Weather conditions', region: 'sidebar', order: 20, defaultOn: true }, + { id: 'heartbeat', description: 'Heartbeat monitor', region: 'sidebar', order: 30, defaultOn: true } +] + +// ── Registry ────────────────────────────────────────────────────────── +export function buildWidgets(ctx: WidgetCtx, state?: WidgetRenderState): WidgetSpec[] { + const bt = bloombergTheme(ctx.t) + const enabled = state?.enabled + const params = state?.params + const on = (id: string) => (enabled ? (enabled[id] ?? false) : true) + const param = (id: string, key: string) => params?.[id]?.[key] + + const all: WidgetSpec[] = [ + { + id: 'ticker', + node: , + order: 10, + region: 'dock' + }, + { id: 'world-clock', node: , order: 10, region: 'sidebar' }, + { id: 'weather', node: , order: 20, region: 'sidebar' }, + { id: 'heartbeat', node: , order: 30, region: 'sidebar' } + ] + + return all.filter(w => on(w.id)) +} + +export function widgetsInRegion(widgets: WidgetSpec[], region: WidgetRegion) { + return [...widgets].filter(w => w.region === region).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) +} + +export function WidgetHost({ region, widgets }: { region: WidgetRegion; widgets: WidgetSpec[] }) { + const visible = widgetsInRegion(widgets, region) + + if (!visible.length) { + return null + } + + if (region === 'overlay') { + return ( + <> + {visible.map(w => ( + {w.node} + ))} + + ) + } + + return ( + + {visible.map((w, i) => ( + + {w.node} + + ))} + + ) +} From cb7b740e32885db5ec471e8ea0966ade106bfc22 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 14:35:42 -0500 Subject: [PATCH 113/157] feat: add subagent details --- run_agent.py | 9 +- ui-tui/src/__tests__/widgets.test.ts | 179 -------- ui-tui/src/app.tsx | 57 +-- ui-tui/src/app/widgetStore.ts | 40 -- ui-tui/src/components/sidebarRail.tsx | 15 - ui-tui/src/components/thinking.tsx | 81 +++- ui-tui/src/hooks/useCompletion.ts | 1 + ui-tui/src/widgets.tsx | 576 -------------------------- 8 files changed, 92 insertions(+), 866 deletions(-) delete mode 100644 ui-tui/src/__tests__/widgets.test.ts delete mode 100644 ui-tui/src/app/widgetStore.ts delete mode 100644 ui-tui/src/components/sidebarRail.tsx delete mode 100644 ui-tui/src/widgets.tsx diff --git a/run_agent.py b/run_agent.py index 956093748c..8731cbaa0f 100644 --- a/run_agent.py +++ b/run_agent.py @@ -10122,7 +10122,14 @@ class AIAgent: elif self.quiet_mode: clean = self._strip_think_blocks(turn_content).strip() if clean: - self._vprint(f" ┊ 💬 {clean}") + relayed = False + if ( + self.tool_progress_callback + and getattr(self, "platform", "") == "tui" + ): + relayed = True + if not relayed: + self._vprint(f" ┊ 💬 {clean}") # Pop thinking-only prefill message(s) before appending # (tool-call path — same rationale as the final-response path). diff --git a/ui-tui/src/__tests__/widgets.test.ts b/ui-tui/src/__tests__/widgets.test.ts deleted file mode 100644 index 39beef9081..0000000000 --- a/ui-tui/src/__tests__/widgets.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { DEFAULT_THEME } from '../theme.js' -import type { WidgetSpec } from '../widgets.js' -import { - bloombergTheme, - buildWidgets, - cityTime, - livePoints, - marquee, - plotLineRows, - sparkline, - widgetsInRegion, - wrapWindow -} from '../widgets.js' - -const BASE_CTX = { - bgCount: 0, - busy: false, - cols: 120, - cwdLabel: '~/hermes-agent', - durationLabel: '11s', - model: 'claude', - status: 'idle', - t: DEFAULT_THEME, - usage: { calls: 0, input: 0, output: 0, total: 0 }, - voiceLabel: 'voice off' -} - -describe('sparkline', () => { - it('respects requested width', () => { - expect([...sparkline([1, 2, 3, 4, 5], 3)]).toHaveLength(3) - }) - - it('is stable for flat series', () => { - const line = sparkline([7, 7, 7, 7], 4) - - expect([...line]).toHaveLength(4) - expect(new Set([...line]).size).toBe(1) - }) -}) - -describe('widgetsInRegion', () => { - it('filters and sorts by region + order', () => { - const widgets: WidgetSpec[] = [ - { id: 'c', node: 'c', order: 20, region: 'dock' }, - { id: 'a', node: 'a', order: 5, region: 'dock' }, - { id: 'b', node: 'b', order: 1, region: 'overlay' } - ] - - expect(widgetsInRegion(widgets, 'dock').map(w => w.id)).toEqual(['a', 'c']) - }) -}) - -describe('wrapWindow', () => { - it('wraps around the array', () => { - expect(wrapWindow([1, 2, 3], 2, 5)).toEqual([3, 1, 2, 3, 1]) - }) -}) - -describe('marquee', () => { - it('returns fixed-width slice', () => { - expect(marquee('abc', 0, 5)).toHaveLength(5) - expect(marquee('abc', 1, 5)).not.toEqual(marquee('abc', 0, 5)) - }) -}) - -describe('plotLineRows', () => { - it('returns the requested height', () => { - expect(plotLineRows([1, 2, 3, 4], 4, 3)).toHaveLength(3) - }) - - it('each row has the requested width', () => { - const rows = plotLineRows([1, 4, 2, 5], 20, 4) - - for (const row of rows) { - expect([...row]).toHaveLength(20) - } - }) - - it('draws visible braille for varied data', () => { - expect(plotLineRows([1, 4, 2, 5], 8, 3).join('')).toMatch(/[^\u2800 ]/) - }) -}) - -describe('livePoints', () => { - const asset = { label: 'TEST', series: [100, 102, 101, 103, 105] } - - it('extends the series by one', () => { - expect(livePoints(asset, 0)).toHaveLength(asset.series.length + 1) - }) - - it('starts at series[0]', () => { - expect(livePoints(asset, 0)[0]).toBe(100) - }) - - it('live point stays within ±2% of last value', () => { - const last = asset.series.at(-1)! - - for (let t = 0; t < 50; t++) { - const pts = livePoints(asset, t) - const live = pts.at(-1)! - - expect(live).toBeGreaterThan(last * 0.98) - expect(live).toBeLessThan(last * 1.02) - } - }) -}) - -describe('cityTime', () => { - it('returns HH:MM:SS format', () => { - expect(cityTime('America/New_York')).toMatch(/^\d{2}:\d{2}:\d{2}$/) - }) - - it('works for all clock zones', () => { - for (const tz of ['America/New_York', 'Europe/London', 'Asia/Tokyo', 'Australia/Sydney']) { - expect(cityTime(tz)).toMatch(/^\d{2}:\d{2}:\d{2}$/) - } - }) -}) - -describe('buildWidgets', () => { - it('routes widgets into dock + sidebar regions', () => { - const widgets = buildWidgets({ ...BASE_CTX, blocked: true }) - const byId = new Map(widgets.map(w => [w.id, w.region])) - - expect(byId.get('ticker')).toBe('dock') - expect(byId.get('world-clock')).toBe('sidebar') - expect(byId.get('weather')).toBe('sidebar') - expect(byId.get('heartbeat')).toBe('sidebar') - }) - - it('filters by enabled map', () => { - const enabled = { ticker: true, 'world-clock': false, weather: true, heartbeat: false } - const widgets = buildWidgets(BASE_CTX, { enabled }) - const ids = widgets.map(w => w.id) - - expect(ids).toContain('ticker') - expect(ids).toContain('weather') - expect(ids).not.toContain('world-clock') - expect(ids).not.toContain('heartbeat') - }) - - it('accepts widget params config', () => { - const widgets = buildWidgets(BASE_CTX, { - enabled: { ticker: true, 'world-clock': true, weather: true, heartbeat: true }, - params: { ticker: { asset: 'ETH' } } - }) - - expect(widgets.map(w => w.id)).toContain('ticker') - }) - - it('returns all when no enabled map given', () => { - const widgets = buildWidgets({ ...BASE_CTX, blocked: false }) - - expect(widgets.some(w => w.region === 'overlay')).toBe(false) - expect(widgets.some(w => w.region === 'dock')).toBe(true) - }) - - it('includes all expected widget ids', () => { - const ids = buildWidgets(BASE_CTX).map(w => w.id) - - expect(ids).toContain('ticker') - expect(ids).toContain('weather') - expect(ids).toContain('world-clock') - expect(ids).toContain('heartbeat') - }) -}) - -describe('bloombergTheme', () => { - it('overrides color keys while preserving brand', () => { - const bt = bloombergTheme(DEFAULT_THEME) - - expect(bt.brand).toEqual(DEFAULT_THEME.brand) - expect(bt.color.cornsilk).toBe('#FFFFFF') - expect(bt.color.statusGood).toBe('#00EE00') - expect(bt.color.statusBad).toBe('#FF2200') - }) -}) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 98e6149b1d..549314abdf 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -109,12 +109,6 @@ export function App({ gw }: { gw: GatewayClient }) { const composerActions = composer.actions const composerRefs = composer.refs const composerState = composer.state - const composerCompletions = composerState.completions - const composerCompIdx = composerState.compIdx - const composerInput = composerState.input - const composerInputBuf = composerState.inputBuf - const composerQueueEditIdx = composerState.queueEditIdx - const composerQueuedDisplay = composerState.queuedDisplay const empty = !historyItems.some(msg => msg.kind !== 'intro') @@ -232,10 +226,6 @@ export function App({ gw }: { gw: GatewayClient }) { [sys] ) - const pushActivity = turnActions.pushActivity - const pruneTransient = turnActions.pruneTransient - const pushTrail = turnActions.pushTrail - const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const display = cfg?.config?.display ?? {} @@ -353,7 +343,7 @@ export function App({ gw }: { gw: GatewayClient }) { return } - pushActivity('MCP reloaded after config change') + turnActions.pushActivity('MCP reloaded after config change') }) rpc('config.get', { key: 'full' }).then(applyDisplayConfig) } else if (!configMtimeRef.current && next) { @@ -363,7 +353,7 @@ export function App({ gw }: { gw: GatewayClient }) { }, 5000) return () => clearInterval(id) - }, [applyDisplayConfig, pushActivity, rpc, ui.sid]) + }, [applyDisplayConfig, turnActions, rpc, ui.sid]) const idle = turnActions.idle const clearReasoning = turnActions.clearReasoning @@ -606,9 +596,9 @@ export function App({ gw }: { gw: GatewayClient }) { if (r?.matched) { if (r.is_image) { const meta = imageTokenMeta(r) - pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + turnActions.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) } else { - pushActivity(`detected file: ${r.name}`) + turnActions.pushActivity(`detected file: ${r.name}`) } startSubmit(r.text || text, expandPasteSnips(r.text || text)) @@ -620,7 +610,7 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => startSubmit(text, expandPasteSnips(text))) }, - [appendMessage, composerState.pasteSnips, gw, pushActivity, sys, turnRefs] + [appendMessage, composerState.pasteSnips, gw, turnActions, sys, turnRefs] ) const shellExec = useCallback( @@ -850,10 +840,10 @@ export function App({ gw }: { gw: GatewayClient }) { clearReasoning, endReasoningPhase: turnActions.endReasoningPhase, idle, - pruneTransient, + pruneTransient: turnActions.pruneTransient, pulseReasoningStreaming: turnActions.pulseReasoningStreaming, - pushActivity, - pushTrail, + pushActivity: turnActions.pushActivity, + pushTrail: turnActions.pushTrail, scheduleReasoning: turnActions.scheduleReasoning, scheduleStreaming: turnActions.scheduleStreaming, setActivity: turnActions.setActivity, @@ -888,9 +878,6 @@ export function App({ gw }: { gw: GatewayClient }) { gateway, idle, newSession, - pruneTransient, - pushActivity, - pushTrail, resetSession, sendQueued, sys, @@ -907,7 +894,7 @@ export function App({ gw }: { gw: GatewayClient }) { const exitHandler = () => { patchUiState({ busy: false, sid: null, status: 'gateway exited' }) - pushActivity('gateway exited · /logs to inspect', 'error') + turnActions.pushActivity('gateway exited · /logs to inspect', 'error') sys('error: gateway exited') } @@ -920,7 +907,7 @@ export function App({ gw }: { gw: GatewayClient }) { gw.off('exit', exitHandler) gw.kill() } - }, [gw, pushActivity, sys]) + }, [gw, turnActions, sys]) // ── Slash commands ─────────────────────────────────────────────── @@ -1162,27 +1149,27 @@ export function App({ gw }: { gw: GatewayClient }) { const appComposer = useMemo( () => ({ cols, - compIdx: composerCompIdx, - completions: composerCompletions, + compIdx: composerState.compIdx, + completions: composerState.completions, empty, handleTextPaste, - input: composerInput, - inputBuf: composerInputBuf, + input: composerState.input, + inputBuf: composerState.inputBuf, pagerPageSize, - queueEditIdx: composerQueueEditIdx, - queuedDisplay: composerQueuedDisplay, + queueEditIdx: composerState.queueEditIdx, + queuedDisplay: composerState.queuedDisplay, submit, updateInput: composerActions.setInput }), [ cols, composerActions.setInput, - composerCompIdx, - composerCompletions, - composerInput, - composerInputBuf, - composerQueueEditIdx, - composerQueuedDisplay, + composerState.compIdx, + composerState.completions, + composerState.input, + composerState.inputBuf, + composerState.queueEditIdx, + composerState.queuedDisplay, empty, handleTextPaste, pagerPageSize, diff --git a/ui-tui/src/app/widgetStore.ts b/ui-tui/src/app/widgetStore.ts deleted file mode 100644 index 51fe3e821a..0000000000 --- a/ui-tui/src/app/widgetStore.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { atom } from 'nanostores' - -import { WIDGET_CATALOG } from '../widgets.js' - -export interface WidgetState { - enabled: Record - params: Record> -} - -function defaults(): WidgetState { - const enabled: Record = {} - - for (const w of WIDGET_CATALOG) { - enabled[w.id] = w.defaultOn - } - - return { enabled, params: {} } -} - -export const $widgetState = atom(defaults()) - -export function toggleWidget(id: string, force?: boolean) { - const s = $widgetState.get() - const next = force ?? !s.enabled[id] - - $widgetState.set({ ...s, enabled: { ...s.enabled, [id]: next } }) - - return next -} - -export function setWidgetParam(id: string, key: string, value: string) { - const s = $widgetState.get() - const prev = s.params[id] ?? {} - - $widgetState.set({ ...s, params: { ...s.params, [id]: { ...prev, [key]: value } } }) -} - -export function getWidgetEnabled(id: string): boolean { - return $widgetState.get().enabled[id] ?? false -} diff --git a/ui-tui/src/components/sidebarRail.tsx b/ui-tui/src/components/sidebarRail.tsx deleted file mode 100644 index 80b52078c1..0000000000 --- a/ui-tui/src/components/sidebarRail.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Box, NoSelect } from '@hermes/ink' - -import type { Theme } from '../theme.js' -import type { WidgetSpec } from '../widgets.js' -import { WidgetHost } from '../widgets.js' - -export function SidebarRail({ t, widgets, width }: { t: Theme; widgets: WidgetSpec[]; width: number }) { - return ( - - - - - - ) -} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index afd00e4a2b..27bf5e0736 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -103,7 +103,7 @@ function Chevron({ tone = 'dim' }: { count?: number - onClick: () => void + onClick: (deep?: boolean) => void open: boolean suffix?: string t: Theme @@ -113,7 +113,7 @@ function Chevron({ const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim return ( - + onClick(!!e?.shiftKey || !!e?.ctrlKey)}> {open ? '▾ ' : '▸ '} {title} @@ -131,6 +131,7 @@ function Chevron({ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) { const [open, setOpen] = useState(expanded) + const [deep, setDeep] = useState(expanded) const [openThinking, setOpenThinking] = useState(expanded) const [openTools, setOpenTools] = useState(expanded) const [openNotes, setOpenNotes] = useState(expanded) @@ -141,16 +142,26 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub } setOpen(true) + setDeep(true) setOpenThinking(true) setOpenTools(true) setOpenNotes(true) }, [expanded]) + const expandAll = () => { + setOpen(true) + setDeep(true) + setOpenThinking(true) + setOpenTools(true) + setOpenNotes(true) + } + const statusTone: 'dim' | 'error' | 'warn' = item.status === 'failed' ? 'error' : item.status === 'interrupted' ? 'warn' : 'dim' const prefix = item.taskCount > 1 ? `[${item.index + 1}/${item.taskCount}] ` : '' - const title = `${prefix}${item.goal || `Subagent ${item.index + 1}`}` + const goalLabel = item.goal || `Subagent ${item.index + 1}` + const title = `${prefix}${open ? goalLabel : compactPreview(goalLabel, 60)}` const summary = compactPreview((item.summary || '').replace(/\s+/g, ' ').trim(), 72) const suffix = @@ -163,23 +174,32 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub const hasTools = item.tools.length > 0 const noteRows = [...(summary ? [summary] : []), ...item.notes] const hasNotes = noteRows.length > 0 - const active = expanded || open + const showChildren = expanded || deep return ( - setOpen(v => !v)} open={active} suffix={suffix} t={t} title={title} tone={statusTone} /> - {active && ( + shift ? expandAll() : setOpen(v => { if (!v) setDeep(false); return !v })} + open={open} + suffix={suffix} + t={t} + title={title} + tone={statusTone} + /> + + {open && ( {hasThinking && ( <> setOpenThinking(v => !v)} - open={expanded || openThinking} + onClick={shift => { if (shift) expandAll(); else setOpenThinking(v => !v) }} + open={showChildren || openThinking} t={t} title="Thinking" /> - {(expanded || openThinking) && ( + + {(showChildren || openThinking) && ( setOpenTools(v => !v)} - open={expanded || openTools} + onClick={shift => { if (shift) expandAll(); else setOpenTools(v => !v) }} + open={showChildren || openTools} t={t} title="Tool calls" /> - {(expanded || openTools) && ( + + {(showChildren || openTools) && ( {item.tools.map((line, index) => ( @@ -217,13 +238,14 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub <> setOpenNotes(v => !v)} - open={expanded || openNotes} + onClick={shift => { if (shift) expandAll(); else setOpenNotes(v => !v) }} + open={showChildren || openNotes} t={t} title="Progress" tone={statusTone} /> - {(expanded || openNotes) && ( + + {(showChildren || openNotes) && ( {noteRows.map((line, index) => ( { @@ -519,7 +542,9 @@ export const ToolTrail = memo(function ToolTrail({ : null const subagentBlock = hasSubagents - ? subagents.map(item => ) + ? subagents.map(item => ( + + )) : null const metaBlock = hasMeta @@ -554,6 +579,14 @@ export const ToolTrail = memo(function ToolTrail({ // ── Collapsed: clickable accordions ──────────────────────────── + const expandAll = () => { + setOpenThinking(true) + setOpenTools(true) + setOpenSubagents(true) + setDeepSubagents(true) + setOpenMeta(true) + } + const metaTone: 'dim' | 'error' | 'warn' = activity.some(i => i.tone === 'error') ? 'error' : activity.some(i => i.tone === 'warn') @@ -564,7 +597,7 @@ export const ToolTrail = memo(function ToolTrail({ {hasThinking && ( <> - setOpenThinking(v => !v)}> + (e?.shiftKey || e?.ctrlKey) ? expandAll() : setOpenThinking(v => !v)}> {openThinking ? '▾ ' : '▸ '} @@ -586,7 +619,7 @@ export const ToolTrail = memo(function ToolTrail({ <> setOpenTools(v => !v)} + onClick={shift => shift ? expandAll() : setOpenTools(v => !v)} open={openTools} suffix={toolTokensLabel} t={t} @@ -600,7 +633,15 @@ export const ToolTrail = memo(function ToolTrail({ <> setOpenSubagents(v => !v)} + onClick={shift => { + if (shift) { + expandAll() + setDeepSubagents(true) + } else { + setOpenSubagents(v => !v) + setDeepSubagents(false) + } + }} open={openSubagents} t={t} title="Subagents" @@ -613,7 +654,7 @@ export const ToolTrail = memo(function ToolTrail({ <> setOpenMeta(v => !v)} + onClick={shift => shift ? expandAll() : setOpenMeta(v => !v)} open={openMeta} t={t} title="Activity" diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index 70dbb536f6..c6ba28c808 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -4,6 +4,7 @@ import type { CompletionItem } from '../app/interfaces.js' import type { GatewayClient } from '../gatewayClient.js' import type { CompletionResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' + const TAB_PATH_RE = /((?:["']?(?:[A-Za-z]:[\\/]|\.{1,2}\/|~\/|\/|@|[^"'`\s]+\/))[^\s]*)$/ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient) { diff --git a/ui-tui/src/widgets.tsx b/ui-tui/src/widgets.tsx deleted file mode 100644 index ffc9a864f8..0000000000 --- a/ui-tui/src/widgets.tsx +++ /dev/null @@ -1,576 +0,0 @@ -import { Box, Text } from '@hermes/ink' -import { Fragment, type ReactNode, useEffect, useState } from 'react' - -import type { Theme } from './theme.js' -import type { Usage } from './types.js' - -// ── Region types ────────────────────────────────────────────────────── -export const WIDGET_REGIONS = [ - 'transcript-header', - 'transcript-inline', - 'transcript-tail', - 'dock', - 'overlay', - 'sidebar' -] as const -export type WidgetRegion = (typeof WIDGET_REGIONS)[number] - -export interface WidgetCtx { - blocked?: boolean - bgCount: number - busy: boolean - cols: number - cwdLabel?: string - durationLabel?: string - model?: string - status: string - t: Theme - // tick is intentionally NOT here — each widget calls useWidgetTicker() internally. - // Passing tick via props caused useMemo in AppLayout to rebuild JSX on every second, - // which created stale prop snapshots and broke animated text rendering. - usage: Usage - voiceLabel?: string -} - -export interface WidgetSpec { - id: string - node: ReactNode - order?: number - region: WidgetRegion - // Optional: theme transform applied to `t` before rendering. This lets - // individual widgets opt into a different color palette (e.g. Bloomberg) - // without touching the main app theme. - themeOverride?: (base: Theme) => Theme -} - -export interface WidgetRenderState { - enabled?: Record - params?: Record> -} - -// ── Theme overrides ─────────────────────────────────────────────────── -// Bloomberg terminal palette: high-contrast orange/green/red on dark intent. -export function bloombergTheme(t: Theme): Theme { - return { - ...t, - color: { - ...t.color, - gold: '#FFE000', // bright yellow titles - amber: '#FF8C00', // orange for values - bronze: '#FF6600', // orange borders - cornsilk: '#FFFFFF', // white for primary text - dim: '#777777', // gray secondary - label: '#FFCC00', // amber labels - statusGood: '#00EE00', // classic Bloomberg green - statusBad: '#FF2200', // Bloomberg red - statusWarn: '#FFAA00' - } - } -} - -// ── Data ───────────────────────────────────────────────────────────── -interface Asset { - label: string - series: number[] -} - -const BTC: number[] = [ - 83900, 84210, 85140, 84680, 85990, 86540, 87310, 86820, 87940, 88600, 89200, 90100, 90560, 91840, 91210, 92680, 92100, - 91500, 92900, 93200 -] - -const ETH: number[] = [ - 2920, 2960, 3015, 2990, 3070, 3050, 3110, 3080, 3160, 3200, 3225, 3260, 3290, 3250, 3310, 3330, 3385, 3360, 3400, 3420 -] - -const NVDA: number[] = [ - 125, 128, 129, 127, 131, 130, 133, 132, 136, 137, 139, 138, 141, 140, 142, 143, 145, 144, 146, 148 -] - -const TSLA: number[] = [ - 172, 176, 178, 175, 181, 180, 184, 182, 188, 187, 191, 189, 195, 193, 196, 198, 201, 199, 203, 205 -] - -const TEMP_DAY: number[] = [65, 66, 67, 69, 71, 73, 74, 75, 74, 73, 72, 71, 70, 69, 68] - -const USD = new Intl.NumberFormat('en-US', { currency: 'USD', maximumFractionDigits: 0, style: 'currency' }) -const BARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'] -const ORBS = ['◐', '◓', '◑', '◒'] - -const ASSETS: Asset[] = [ - { label: 'BTC', series: BTC }, - { label: 'ETH', series: ETH }, - { label: 'NVDA', series: NVDA }, - { label: 'TSLA', series: TSLA } -] - -const CLOCKS = [ - { label: 'NYC', tz: 'America/New_York' }, - { label: 'LON', tz: 'Europe/London' }, - { label: 'TKY', tz: 'Asia/Tokyo' }, - { label: 'SYD', tz: 'Australia/Sydney' } -] - -const SKY_DESC = ['Overcast', 'Partly cloudy', 'Clear'] -const SKY_ICON = ['☁', '⛅', '☀'] - -// ── Ticker ──────────────────────────────────────────────────────────── -export function useWidgetTicker(ms = 1000) { - const [tick, setTick] = useState(0) - - useEffect(() => { - const id = setInterval(() => setTick(v => v + 1), ms) - - return () => clearInterval(id) - }, [ms]) - - return tick -} - -// ── Pure math helpers ───────────────────────────────────────────────── -// Always resamples to exactly `width` points (no early return for small input). -// The old `if (values.length <= width) return values` caused the braille grid -// to only fill the first N pixel-columns when N < pxWidth. -function sample(values: number[], width: number): number[] { - if (!values.length || width <= 0) { - return [] - } - - if (width === 1) { - return [values.at(-1) ?? 0] - } - - return Array.from({ length: width }, (_, i) => { - const pos = (i * (values.length - 1)) / (width - 1) - - return values[Math.round(pos)] ?? values.at(-1) ?? 0 - }) -} - -export function sparkline(values: number[], width: number): string { - const pts = sample(values, width) - - if (!pts.length) { - return '' - } - - const lo = Math.min(...pts) - const hi = Math.max(...pts) - - if (lo === hi) { - return BARS[Math.floor(BARS.length / 2)]!.repeat(pts.length) - } - - return pts - .map(v => BARS[Math.max(0, Math.min(BARS.length - 1, Math.round(((v - lo) / (hi - lo)) * (BARS.length - 1))))]!) - .join('') -} - -export function wrapWindow(values: T[], offset: number, width: number): T[] { - if (!values.length || width <= 0) { - return [] - } - - const start = ((offset % values.length) + values.length) % values.length - - return Array.from({ length: width }, (_, i) => values[(start + i) % values.length]!) -} - -export function marquee(text: string, offset: number, width: number): string { - if (!text || width <= 0) { - return '' - } - - const gap = ' ' - const source = text + gap + text - const span = text.length + gap.length - const start = ((offset % span) + span) % span - - return source.slice(start, start + width).padEnd(width, ' ') -} - -// ── Braille line chart ──────────────────────────────────────────────── -function brailleBit(dx: number, dy: number): number { - return dx === 0 ? ([0x1, 0x2, 0x4, 0x40][dy] ?? 0) : ([0x8, 0x10, 0x20, 0x80][dy] ?? 0) -} - -function drawLine(grid: boolean[][], x0: number, y0: number, x1: number, y1: number) { - let x = x0 - let y = y0 - const dx = Math.abs(x1 - x0) - const sx = x0 < x1 ? 1 : -1 - const dy = -Math.abs(y1 - y0) - const sy = y0 < y1 ? 1 : -1 - let err = dx + dy - - while (true) { - if (grid[y] && x >= 0 && x < grid[y]!.length) { - grid[y]![x] = true - } - - if (x === x1 && y === y1) { - return - } - - const e2 = err * 2 - - if (e2 >= dy) { - err += dy - x += sx - } - - if (e2 <= dx) { - err += dx - y += sy - } - } -} - -export function plotLineRows(values: number[], width: number, height: number): string[] { - if (!values.length || width <= 0 || height <= 0) { - return [] - } - - const pxW = Math.max(2, width * 2) - const pxH = Math.max(4, height * 4) - const pts = sample(values, pxW) - const lo = Math.min(...pts) - const hi = Math.max(...pts) - const grid = Array.from({ length: pxH }, () => new Array(pxW).fill(false)) - - const yFor = (v: number) => (hi === lo ? Math.floor((pxH - 1) / 2) : Math.round(((hi - v) / (hi - lo)) * (pxH - 1))) - - let prevY = yFor(pts[0] ?? 0) - - if (grid[prevY]) { - grid[prevY]![0] = true - } - - for (let x = 1; x < pts.length; x++) { - const y = yFor(pts[x] ?? pts[x - 1] ?? 0) - drawLine(grid, x - 1, prevY, x, y) - prevY = y - } - - return Array.from({ length: height }, (_, row) => { - const top = row * 4 - - return Array.from({ length: width }, (_, col) => { - const left = col * 2 - let bits = 0 - - for (let dy = 0; dy < 4; dy++) { - for (let dx = 0; dx < 2; dx++) { - if (grid[top + dy]?.[left + dx]) { - bits |= brailleBit(dx, dy) - } - } - } - - return String.fromCodePoint(0x2800 + bits) - }).join('') - }) -} - -// ── Domain helpers ──────────────────────────────────────────────────── -function pct(now: number, start: number): number { - return !start ? 0 : ((now - start) / start) * 100 -} - -function deltaColor(delta: number, t: Theme): string { - return delta > 0 ? t.color.statusGood : delta < 0 ? t.color.statusBad : t.color.dim -} - -function money(v: number): string { - return USD.format(v) -} - -function changeStr(v: number): string { - return `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` -} - -export function cityTime(tz: string): string { - try { - return new Date().toLocaleTimeString('en-US', { - hour: '2-digit', - hour12: false, - minute: '2-digit', - second: '2-digit', - timeZone: tz - }) - } catch { - return '--:--:--' - } -} - -function fToC(f: number): number { - return Math.round(((f - 32) * 5) / 9) -} - -// Smooth live points — always starts at series[0], adds an animated live -// endpoint oscillating ±0.8% so the chart never has discontinuous jumps. -export function livePoints(asset: Asset, tick: number): number[] { - const last = asset.series.at(-1) ?? 0 - const phase = (tick * 0.3) % (Math.PI * 2) - const live = Math.round(last * (1 + Math.sin(phase) * 0.008)) - - return [...asset.series, live] -} - -// ── Primitive components ────────────────────────────────────────────── -function LineChart({ color, height, values, width }: { color: any; height: number; values: number[]; width: number }) { - const rows = plotLineRows(values, width, height) - - return ( - - {rows.map((row, i) => ( - - {row} - - ))} - - ) -} - -// Simple widget frame system: -// - bordered widgets: default card chrome -// - bleed widgets: full-surface background with internal padding -function WidgetFrame({ - backgroundColor, - bordered = true, - borderColor, - children, - paddingX = 1, - paddingY = 0, - title, - titleRight, - titleTone, - t -}: { - backgroundColor?: any - bordered?: boolean - borderColor?: any - children: ReactNode - paddingX?: number - paddingY?: number - title: ReactNode - titleRight?: ReactNode - titleTone?: any - t: Theme -}) { - return ( - - - - {typeof title === 'string' || typeof title === 'number' ? ( - - {title} - - ) : ( - title - )} - - {titleRight ? ( - - {typeof titleRight === 'string' || typeof titleRight === 'number' ? ( - {titleRight} - ) : ( - titleRight - )} - - ) : null} - - {children} - - ) -} - -// ── Widgets ─────────────────────────────────────────────────────────── - -// Bloomberg-styled hero chart. -// Dark navy background for the chart cell so the green/red line pops. -// Custom layout (not Card) for full control over the title row structure. -// Compact single-line ticker strip for the dock region. -function TickerStrip({ cols, t, assetId }: WidgetCtx & { assetId?: string }) { - const tick = useWidgetTicker(1200) - - const asset = ASSETS.find(a => a.label.toLowerCase() === assetId?.toLowerCase()) ?? ASSETS[tick % ASSETS.length]! - - const pts = livePoints(asset, tick) - const last = pts.at(-1) ?? 0 - const first = pts[0] ?? 0 - const change = pct(last, first) - const color = deltaColor(change, t) - const sparkW = Math.max(8, Math.min(30, cols - 40)) - - const others = ASSETS.filter(a => a.label !== asset.label) - .map(a => { - const c = pct(a.series.at(-1) ?? 0, a.series[0] ?? 0) - - return `${a.label} ${changeStr(c)}` - }) - .join(' ') - - return ( - - - {asset.label} - - {` ${money(last)} `} - - {changeStr(change)} - - {` ${sparkline(pts, sparkW)} `} - {others} - - ) -} - -function WeatherCard({ t }: WidgetCtx) { - const tick = useWidgetTicker(2000) - const skyIdx = Math.floor(tick / 8) % SKY_DESC.length - const desc = SKY_DESC[skyIdx]! - const icon = SKY_ICON[skyIdx]! - const temp = TEMP_DAY[tick % TEMP_DAY.length]! - const wind = 9 + ((tick * 2) % 7) - const hum = 64 + (tick % 8) - - return ( - - - Weather - - {`${icon} ${fToC(temp)}C · ${desc}`} - {`Wind ${wind} km/h · Humidity ${hum}%`} - - ) -} - -// 2x2 clock grid in compact rows -function WorldClock({ cols, t }: WidgetCtx) { - const tick = useWidgetTicker() - const orb = ORBS[tick % ORBS.length]! - const rows = [CLOCKS.slice(0, 2), CLOCKS.slice(2, 4)] as const - const slotW = Math.max(12, Math.floor((Math.max(cols, 24) - 2) / 2)) - const cell = (label: string, tz: string) => `${label} ${cityTime(tz)}` - - return ( - - {`${orb} World Clock`} - {rows.map((row, i) => ( - - - - {cell(row[0]!.label, row[0]!.tz)} - - - - - {cell(row[1]!.label, row[1]!.tz)} - - - - ))} - - ) -} - -function HeartBeat({ t }: WidgetCtx) { - const tick = useWidgetTicker(700) - const bpm = 70 + (tick % 5) - - return ( - - - Heartbeat - - {`❤️ ${bpm} bpm`} - - ) -} - -// ── Widget catalog ──────────────────────────────────────────────────── -export interface WidgetDef { - id: string - description: string - region: WidgetRegion - order: number - defaultOn: boolean - params?: string[] -} - -export const WIDGET_CATALOG: WidgetDef[] = [ - { - id: 'ticker', - description: 'Live stock ticker strip', - region: 'dock', - order: 10, - defaultOn: true, - params: ['asset'] - }, - { id: 'world-clock', description: '2x2 world clock grid', region: 'sidebar', order: 10, defaultOn: true }, - { id: 'weather', description: 'Weather conditions', region: 'sidebar', order: 20, defaultOn: true }, - { id: 'heartbeat', description: 'Heartbeat monitor', region: 'sidebar', order: 30, defaultOn: true } -] - -// ── Registry ────────────────────────────────────────────────────────── -export function buildWidgets(ctx: WidgetCtx, state?: WidgetRenderState): WidgetSpec[] { - const bt = bloombergTheme(ctx.t) - const enabled = state?.enabled - const params = state?.params - const on = (id: string) => (enabled ? (enabled[id] ?? false) : true) - const param = (id: string, key: string) => params?.[id]?.[key] - - const all: WidgetSpec[] = [ - { - id: 'ticker', - node: , - order: 10, - region: 'dock' - }, - { id: 'world-clock', node: , order: 10, region: 'sidebar' }, - { id: 'weather', node: , order: 20, region: 'sidebar' }, - { id: 'heartbeat', node: , order: 30, region: 'sidebar' } - ] - - return all.filter(w => on(w.id)) -} - -export function widgetsInRegion(widgets: WidgetSpec[], region: WidgetRegion) { - return [...widgets].filter(w => w.region === region).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) -} - -export function WidgetHost({ region, widgets }: { region: WidgetRegion; widgets: WidgetSpec[] }) { - const visible = widgetsInRegion(widgets, region) - - if (!visible.length) { - return null - } - - if (region === 'overlay') { - return ( - <> - {visible.map(w => ( - {w.node} - ))} - - ) - } - - return ( - - {visible.map((w, i) => ( - - {w.node} - - ))} - - ) -} From 57e4b61155285cb672f9e179fe668bb0f0f6f703 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 16:34:58 -0500 Subject: [PATCH 114/157] feat: change to $ when in ! mode --- ui-tui/src/components/appLayout.tsx | 20 +++++++++++++++----- ui-tui/src/theme.ts | 8 ++++++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 728a8fcce5..b6b6dac572 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -22,6 +22,7 @@ const TranscriptPane = memo(function TranscriptPane({ transcript }: Pick) { const ui = useStore($uiState) + const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end) return ( @@ -35,6 +36,7 @@ const TranscriptPane = memo(function TranscriptPane({ {row.msg.kind === 'intro' && row.msg.info ? ( + ) : row.msg.kind === 'panel' && row.msg.panelData ? ( @@ -105,6 +107,9 @@ const ComposerPane = memo(function ComposerPane({ const ui = useStore($uiState) const isBlocked = useStore($isBlocked) + const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') + const pw = sh ? 2 : 3 + return ( + {status.stickyPrompt} ) : ( @@ -172,14 +178,18 @@ const ComposerPane = memo(function ComposerPane({ ))} - - - {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} - + + {sh ? ( + $ + ) : ( + + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} + + )} Date: Wed, 15 Apr 2026 17:43:38 -0500 Subject: [PATCH 115/157] feat: good vibes indi --- ui-tui/src/app.tsx | 70 ++- ui-tui/src/app/createSlashHandler.ts | 72 ++- ui-tui/src/app/interfaces.ts | 1 + ui-tui/src/components/appChrome.tsx | 55 +- ui-tui/src/components/appLayout.tsx | 26 +- ui-tui/src/components/thinking.tsx | 837 ++++++++++++++++++--------- 6 files changed, 763 insertions(+), 298 deletions(-) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 549314abdf..947af602a9 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -29,6 +29,13 @@ import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js' import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js' +const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i +const LONG_RUN_CHARM_DELAY_MS = 8_000 +const LONG_RUN_CHARM_INTERVAL_MS = 10_000 +const LONG_RUN_CHARM_MAX = 2 + +const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] + // ── App ────────────────────────────────────────────────────────────── export function App({ gw }: { gw: GatewayClient }) { @@ -68,6 +75,7 @@ export function App({ gw }: { gw: GatewayClient }) { const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) const ui = useStore($uiState) const overlay = useStore($overlayState) @@ -85,6 +93,7 @@ export function App({ gw }: { gw: GatewayClient }) { const configMtimeRef = useRef(0) const historyItemsRef = useRef(historyItems) const lastUserMsgRef = useRef(lastUserMsg) + const longRunCharmRef = useRef(new Map()) const msgIdsRef = useRef(new WeakMap()) const nextMsgIdRef = useRef(0) colsRef.current = cols @@ -226,6 +235,17 @@ export function App({ gw }: { gw: GatewayClient }) { [sys] ) + const maybeGoodVibes = useCallback( + (text: string) => { + if (!GOOD_VIBES_RE.test(text)) { + return + } + + setGoodVibesTick(v => v + 1) + }, + [] + ) + const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const display = cfg?.config?.display ?? {} @@ -571,6 +591,7 @@ export function App({ gw }: { gw: GatewayClient }) { turnRefs.statusTimerRef.current = null } + maybeGoodVibes(submitText) setLastUserMsg(text) appendMessage({ role: 'user', text: displayText }) patchUiState({ busy: true, status: 'running…' }) @@ -610,7 +631,7 @@ export function App({ gw }: { gw: GatewayClient }) { }) .catch(() => startSubmit(text, expandPasteSnips(text))) }, - [appendMessage, composerState.pasteSnips, gw, turnActions, sys, turnRefs] + [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs] ) const shellExec = useCallback( @@ -909,6 +930,50 @@ export function App({ gw }: { gw: GatewayClient }) { } }, [gw, turnActions, sys]) + useEffect(() => { + if (!ui.busy || !turnState.tools.length) { + longRunCharmRef.current.clear() + + return + } + + const tick = () => { + const now = Date.now() + const liveIds = new Set(turnState.tools.map(tool => tool.id)) + + for (const key of [...longRunCharmRef.current.keys()]) { + if (!liveIds.has(key)) { + longRunCharmRef.current.delete(key) + } + } + + for (const tool of turnState.tools) { + if (!tool.startedAt || now - tool.startedAt < LONG_RUN_CHARM_DELAY_MS) { + continue + } + + const slot = longRunCharmRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } + + if (slot.count >= LONG_RUN_CHARM_MAX || now - slot.lastAt < LONG_RUN_CHARM_INTERVAL_MS) { + continue + } + + slot.count += 1 + slot.lastAt = now + longRunCharmRef.current.set(tool.id, slot) + + const charm = LONG_RUN_CHARMS[Math.floor(Math.random() * LONG_RUN_CHARMS.length)]! + const sec = Math.round((now - tool.startedAt) / 1000) + turnActions.pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) + } + } + + tick() + const id = setInterval(tick, 1000) + + return () => clearInterval(id) + }, [turnActions, turnState.tools, ui.busy]) + // ── Slash commands ─────────────────────────────────────────────── const slash = useMemo( @@ -1198,13 +1263,14 @@ export function App({ gw }: { gw: GatewayClient }) { const appStatus = useMemo( () => ({ cwdLabel, + goodVibesTick, sessionStartedAt: sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel }), - [cwdLabel, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] + [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] ) const appTranscript = useMemo( diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 55dbac86f2..eb5a03583a 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -17,6 +17,56 @@ import type { SlashHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' import { getUiState, patchUiState } from './uiStore.js' +const FORTUNES = [ + 'you are one clean refactor away from clarity', + 'a tiny rename today prevents a huge bug tomorrow', + 'your next commit message will be immaculate', + 'the edge case you are ignoring is already solved in your head', + 'minimal diff, maximal calm', + 'today favors bold deletions over new abstractions', + 'the right helper is already in your codebase', + 'you will ship before overthinking catches up', + 'tests are about to save your future self', + 'your instincts are correctly suspicious of that one branch' +] + +const LEGENDARY_FORTUNES = [ + 'legendary drop: one-line fix, first try', + 'legendary drop: every flaky test passes cleanly', + 'legendary drop: your diff teaches by itself' +] + +const hash = (input: string) => { + let out = 2166136261 + + for (let i = 0; i < input.length; i++) { + out ^= input.charCodeAt(i) + out = Math.imul(out, 16777619) + } + + return out >>> 0 +} + +const fortuneFromScore = (score: number) => { + const rare = score % 20 === 0 + const bag = rare ? LEGENDARY_FORTUNES : FORTUNES + + return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` +} + +const randomFortune = () => { + const score = Math.floor(Math.random() * 0x7fffffff) + + return fortuneFromScore(score) +} + +const dailyFortune = (sid: string | null) => { + const seed = `${sid || 'anon'}|${new Date().toDateString()}` + const score = hash(seed) + + return fortuneFromScore(score) +} + export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer const { gw, rpc } = ctx.gateway @@ -71,7 +121,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b sections.push({ title: 'TUI', - rows: [['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode']] + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ] }) sections.push({ title: 'Hotkeys', rows: HOTKEYS }) @@ -171,6 +224,23 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b sys(`details: ${next}`) } + return true + + case 'fortune': + if (!arg || arg.trim().toLowerCase() === 'random') { + sys(randomFortune()) + + return true + } + + if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) { + sys(dailyFortune(sid)) + + return true + } + + sys('usage: /fortune [random|daily]') + return true case 'copy': { if (!arg && hasSelection) { diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 19756e8d35..719116cb8d 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -399,6 +399,7 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string + goodVibesTick: number sessionStartedAt: number | null showStickyPrompt: boolean statusColor: string diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index fff364689e..cad10f6489 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -46,6 +46,29 @@ function SessionDuration({ startedAt }: { startedAt: number }) { return fmtDuration(now - startedAt) } +export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { + const [active, setActive] = useState(false) + const [color, setColor] = useState(t.color.amber) + + useEffect(() => { + if (tick <= 0) { + return + } + + const options = ['#ff5fa2', '#ff4d6d', t.color.amber] + const picked = options[Math.floor(Math.random() * options.length)]! + + setColor(picked) + setActive(true) + + const id = setTimeout(() => setActive(false), 650) + + return () => clearTimeout(id) + }, [t.color.amber, tick]) + + return {active ? '♥' : ' '} +} + export function StatusRule({ cwdLabel, cols, @@ -85,29 +108,29 @@ export function StatusRule({ return ( - + {'─ '} - {status} - │ {model} - {ctxLabel ? │ {ctxLabel} : null} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} {bar ? ( - + {' │ '} - [{bar}] {pctLabel} + [{bar}] {pctLabel} ) : null} {sessionStartedAt ? ( - + {' │ '} ) : null} - {voiceLabel ? │ {voiceLabel} : null} - {bgCount > 0 ? │ {bgCount} bg : null} + {voiceLabel ? │ {voiceLabel} : null} + {bgCount > 0 ? │ {bgCount} bg : null} - - {cwdLabel} + + {cwdLabel} ) } @@ -116,7 +139,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri return ( {!scrollable ? ( - + {' \n'.repeat(Math.max(0, vp - 1))}{' '} ) : ( <> {thumbTop > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} ) : null} {thumb > 0 ? ( - {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} + {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} ) : null} {vp - thumbTop - thumb > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} ) : null} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index b6b6dac572..3d80e5fb1d 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -7,7 +7,7 @@ import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' -import { StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' +import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { AppOverlays } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' import { MessageLine } from './messageLine.js' @@ -106,7 +106,6 @@ const ComposerPane = memo(function ComposerPane({ }: Pick) { const ui = useStore($uiState) const isBlocked = useStore($isBlocked) - const sh = (composer.inputBuf[0] ?? composer.input).startsWith('!') const pw = sh ? 2 : 3 @@ -177,7 +176,7 @@ const ComposerPane = memo(function ComposerPane({ ))} - + {sh ? ( $ @@ -188,14 +187,19 @@ const ComposerPane = memo(function ComposerPane({ )} - + + + + + + )} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 27bf5e0736..0b1d4e95b4 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -1,4 +1,4 @@ -import { Box, Text } from '@hermes/ink' +import { Box, NoSelect, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' @@ -25,24 +25,132 @@ const fmtElapsed = (ms: number) => { return sec < 10 ? `${sec.toFixed(1)}s` : `${Math.round(sec)}s` } +type TreeBranch = 'mid' | 'last' +type TreeRails = readonly boolean[] + +const nextTreeRails = (rails: TreeRails, branch: TreeBranch) => [...rails, branch === 'mid'] + +const treeLead = (rails: TreeRails, branch: TreeBranch) => + `${rails.map(on => (on ? '│ ' : ' ')).join('')}${branch === 'mid' ? '├─ ' : '└─ '}` + // ── Primitives ─────────────────────────────────────────────────────── -export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { - const [spin] = useState(() => { +function TreeRow({ + branch, + children, + rails = [], + stemColor, + stemDim = true, + t +}: { + branch: TreeBranch + children: ReactNode + rails?: TreeRails + stemColor?: string + stemDim?: boolean + t: Theme +}) { + const lead = treeLead(rails, branch) + + return ( + + + + {lead} + + + + {children} + + + ) +} + +function TreeTextRow({ + branch, + color, + content, + dimColor, + rails = [], + t, + wrap = 'wrap-trim' +}: { + branch: TreeBranch + color: string + content: ReactNode + dimColor?: boolean + rails?: TreeRails + t: Theme + wrap?: 'truncate-end' | 'wrap' | 'wrap-trim' +}) { + const text = dimColor ? ( + + {content} + + ) : ( + + {content} + + ) + + return ( + + {text} + + ) +} + +function TreeNode({ + branch, + children, + header, + open, + rails = [], + t +}: { + branch: TreeBranch + children?: (rails: boolean[]) => ReactNode + header: ReactNode + open: boolean + rails?: TreeRails + t: Theme +}) { + return ( + + + {header} + + {open ? children?.(nextTreeRails(rails, branch)) : null} + + ) +} + +export function Spinner({ + color, + variant = 'think' +}: { + color: string + variant?: 'think' | 'tool' +}) { + const spin = useMemo(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] return { ...raw, frames: raw.frames.map(f => [...f][0] ?? '⠀') } - }) + }, [variant]) const [frame, setFrame] = useState(0) + useEffect(() => { + setFrame(0) + }, [spin]) + useEffect(() => { const id = setInterval(() => setFrame(f => (f + 1) % spin.frames.length), spin.interval) return () => clearInterval(id) }, [spin]) - return {spin.frames[frame]} + return {spin.frames[frame]} } interface DetailRow { @@ -52,13 +160,15 @@ interface DetailRow { key: string } -function Detail({ color, content, dimColor }: DetailRow) { - return ( - - - {content} - - ) +function Detail({ + branch = 'last', + color, + content, + dimColor, + rails = [], + t +}: DetailRow & { branch?: TreeBranch; rails?: TreeRails; t: Theme }) { + return } function StreamCursor({ @@ -86,11 +196,17 @@ function StreamCursor({ return () => clearInterval(id) }, [streaming, visible]) - return visible ? ( - + if (!visible) { + return null + } + + return dimColor ? ( + {streaming && on ? '▍' : ' '} - ) : null + ) : ( + {streaming && on ? '▍' : ' '} + ) } function Chevron({ @@ -113,13 +229,13 @@ function Chevron({ const color = tone === 'error' ? t.color.error : tone === 'warn' ? t.color.warn : t.color.dim return ( - onClick(!!e?.shiftKey || !!e?.ctrlKey)}> - - {open ? '▾ ' : '▸ '} + onClick(!!e?.shiftKey || !!e?.ctrlKey)}> + + {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} {suffix ? ( - + {' '} {suffix} @@ -129,7 +245,19 @@ function Chevron({ ) } -function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: SubagentProgress; t: Theme }) { +function SubagentAccordion({ + branch, + expanded, + item, + rails = [], + t +}: { + branch: TreeBranch + expanded: boolean + item: SubagentProgress + rails?: TreeRails + t: Theme +}) { const [open, setOpen] = useState(expanded) const [deep, setDeep] = useState(expanded) const [openThinking, setOpenThinking] = useState(expanded) @@ -175,95 +303,175 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub const noteRows = [...(summary ? [summary] : []), ...item.notes] const hasNotes = noteRows.length > 0 const showChildren = expanded || deep + const noteColor = statusTone === 'error' ? t.color.error : statusTone === 'warn' ? t.color.warn : t.color.dim + + const sections: { + header: ReactNode + key: string + open: boolean + render: (rails: boolean[]) => ReactNode + }[] = [] + + if (hasThinking) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenThinking(v => !v) + } + }} + open={showChildren || openThinking} + t={t} + title="Thinking" + /> + ), + key: 'thinking', + open: showChildren || openThinking, + render: childRails => ( + + ) + }) + } + + if (hasTools) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenTools(v => !v) + } + }} + open={showChildren || openTools} + t={t} + title="Tool calls" + /> + ), + key: 'tools', + open: showChildren || openTools, + render: childRails => ( + + {item.tools.map((line, index) => ( + + + {line} + + } + key={`${item.id}-tool-${index}`} + rails={childRails} + t={t} + /> + ))} + + ) + }) + } + + if (hasNotes) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenNotes(v => !v) + } + }} + open={showChildren || openNotes} + t={t} + title="Progress" + tone={statusTone} + /> + ), + key: 'notes', + open: showChildren || openNotes, + render: childRails => ( + + {noteRows.map((line, index) => ( + + ))} + + ) + }) + } return ( - - shift ? expandAll() : setOpen(v => { if (!v) setDeep(false); return !v })} - open={open} - suffix={suffix} - t={t} - title={title} - tone={statusTone} - /> + { + if (shift) { + expandAll() - {open && ( - - {hasThinking && ( - <> - { if (shift) expandAll(); else setOpenThinking(v => !v) }} - open={showChildren || openThinking} - t={t} - title="Thinking" - /> + return + } - {(showChildren || openThinking) && ( - - )} - - )} + setOpen(v => { + if (!v) { + setDeep(false) + } - {hasTools && ( - <> - { if (shift) expandAll(); else setOpenTools(v => !v) }} - open={showChildren || openTools} - t={t} - title="Tool calls" - /> - - {(showChildren || openTools) && ( - - {item.tools.map((line, index) => ( - - - {line} - - ))} - - )} - - )} - - {hasNotes && ( - <> - { if (shift) expandAll(); else setOpenNotes(v => !v) }} - open={showChildren || openNotes} - t={t} - title="Progress" - tone={statusTone} - /> - - {(showChildren || openNotes) && ( - - {noteRows.map((line, index) => ( - - {index === noteRows.length - 1 ? '└ ' : '├ '} - {line} - - ))} - - )} - - )} + return !v + }) + }} + open={open} + suffix={suffix} + t={t} + title={title} + tone={statusTone} + /> + } + open={open} + rails={rails} + t={t} + > + {childRails => ( + + {sections.map((section, index) => ( + + {section.render} + + ))} )} - + ) } @@ -271,13 +479,17 @@ function SubagentAccordion({ expanded, item, t }: { expanded: boolean; item: Sub export const Thinking = memo(function Thinking({ active = false, + branch = 'last', mode = 'truncated', + rails = [], reasoning, streaming = false, t }: { active?: boolean + branch?: TreeBranch mode?: ThinkingMode + rails?: TreeRails reasoning: string streaming?: boolean t: Theme @@ -285,39 +497,36 @@ export const Thinking = memo(function Thinking({ const preview = useMemo(() => thinkingPreview(reasoning, mode, THINKING_COT_MAX), [mode, reasoning]) const lines = useMemo(() => preview.split('\n').map(line => line.replace(/\t/g, ' ')), [preview]) + if (!preview && !active) { + return null + } + return ( - - {preview ? ( - mode === 'full' ? ( - - - └{' '} + + + {preview ? ( + mode === 'full' ? ( + lines.map((line, index) => ( + + {line || ' '} + {index === lines.length - 1 ? ( + + ) : null} + + )) + ) : ( + + {preview} + - - {lines.map((line, index) => ( - - {line || ' '} - {index === lines.length - 1 ? ( - - ) : null} - - ))} - - + ) ) : ( - - - {preview} + - ) - ) : active ? ( - - - - - ) : null} - + )} + + ) }) @@ -328,6 +537,7 @@ interface Group { content: ReactNode details: DetailRow[] key: string + label: string } export const ToolTrail = memo(function ToolTrail({ @@ -410,7 +620,8 @@ export const ToolTrail = memo(function ToolTrail({ color: parsed.mark === '✗' ? t.color.error : t.color.cornsilk, content: parsed.detail ? parsed.call : `${parsed.call} ${parsed.mark}`, details: [], - key: `tr-${i}` + key: `tr-${i}`, + label: parsed.call }) if (parsed.detail) { @@ -426,11 +637,14 @@ export const ToolTrail = memo(function ToolTrail({ } if (line.startsWith('drafting ')) { + const label = toolTrailLabel(line.slice(9).replace(/…$/, '').trim()) + groups.push({ color: t.color.cornsilk, - content: toolTrailLabel(line.slice(9).replace(/…$/, '').trim()), + content: label, details: [{ color: t.color.dim, content: 'drafting...', dimColor: true, key: `tr-${i}-d` }], - key: `tr-${i}` + key: `tr-${i}`, + label }) continue @@ -457,13 +671,16 @@ export const ToolTrail = memo(function ToolTrail({ } for (const tool of tools) { + const label = formatToolCall(tool.name, tool.context || '') + groups.push({ color: t.color.cornsilk, key: tool.id, + label, details: [], content: ( <> - {formatToolCall(tool.name, tool.context || '')} + {label} {tool.startedAt ? ` (${fmtElapsed(now - tool.startedAt)})` : ''} ) @@ -493,6 +710,8 @@ export const ToolTrail = memo(function ToolTrail({ const toolTokensLabel = toolTokens !== undefined && toolTokens > 0 ? `~${fmtK(toolTokens)} tokens` : undefined const totalTokensLabel = tokenCount > 0 && toolTokenCount > 0 ? `~${fmtK(totalTokenCount)} total` : null + const delegateGroups = groups.filter(g => g.label.startsWith('Delegate Task')) + const inlineDelegateKey = hasSubagents && delegateGroups.length === 1 ? delegateGroups[0]!.key : null // ── Hidden: errors/warnings only ────────────────────────────── @@ -502,7 +721,7 @@ export const ToolTrail = memo(function ToolTrail({ return alerts.length ? ( {alerts.map(i => ( - + {i.tone === 'error' ? '✗' : '!'} {i.text} ))} @@ -510,74 +729,7 @@ export const ToolTrail = memo(function ToolTrail({ ) : null } - // ── Shared render fragments ──────────────────────────────────── - - const thinkingBlock = hasThinking ? ( - busy ? ( - - ) : cot ? ( - - ) : ( - } - dimColor - key="cot" - /> - ) - ) : null - - const toolBlock = hasTools - ? groups.map(g => ( - - - - {g.content} - - {g.details.map(d => ( - - ))} - - )) - : null - - const subagentBlock = hasSubagents - ? subagents.map(item => ( - - )) - : null - - const metaBlock = hasMeta - ? meta.map((row, i) => ( - - {i === meta.length - 1 ? '└ ' : '├ '} - {row.content} - - )) - : null - - const totalBlock = totalTokensLabel ? ( - - Σ - {totalTokensLabel} - - ) : null - - // ── Expanded: flat, no accordions ────────────────────────────── - - if (detailsMode === 'expanded') { - return ( - - {thinkingBlock} - {toolBlock} - {subagentBlock} - {metaBlock} - {totalBlock} - - ) - } - - // ── Collapsed: clickable accordions ──────────────────────────── + // ── Tree render fragments ────────────────────────────────────── const expandAll = () => { setOpenThinking(true) @@ -593,78 +745,227 @@ export const ToolTrail = memo(function ToolTrail({ ? 'warn' : 'dim' - return ( + const renderSubagentList = (rails: boolean[]) => ( - {hasThinking && ( - <> - (e?.shiftKey || e?.ctrlKey) ? expandAll() : setOpenThinking(v => !v)}> - - {openThinking ? '▾ ' : '▸ '} - + {subagents.map((item, index) => ( + + ))} + + ) + + const sections: { + header: ReactNode + key: string + open: boolean + render: (rails: boolean[]) => ReactNode + }[] = [] + + if (hasThinking) { + sections.push({ + header: ( + { + if (e?.shiftKey || e?.ctrlKey) { + expandAll() + } else { + setOpenThinking(v => !v) + } + }} + > + + {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} + {thinkingLive ? ( + Thinking - {thinkingTokensLabel ? ( - - {' '} - {thinkingTokensLabel} - - ) : null} - - - {openThinking && thinkingBlock} - - )} + ) : ( + + Thinking + + )} + {thinkingTokensLabel ? ( + + {' '} + {thinkingTokensLabel} + + ) : null} + + + ), + key: 'thinking', + open: detailsMode === 'expanded' || openThinking, + render: rails => ( + + ) + }) + } - {hasTools && ( - <> - shift ? expandAll() : setOpenTools(v => !v)} - open={openTools} - suffix={toolTokensLabel} - t={t} - title="Tool calls" - /> - {openTools && toolBlock} - - )} + if (hasTools) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenTools(v => !v) + } + }} + open={detailsMode === 'expanded' || openTools} + suffix={toolTokensLabel} + t={t} + title="Tool calls" + /> + ), + key: 'tools', + open: detailsMode === 'expanded' || openTools, + render: rails => ( + + {groups.map((group, index) => { + const branch: TreeBranch = index === groups.length - 1 ? 'last' : 'mid' + const childRails = nextTreeRails(rails, branch) + const hasInlineSubagents = inlineDelegateKey === group.key - {hasSubagents && ( - <> - { - if (shift) { - expandAll() - setDeepSubagents(true) - } else { - setOpenSubagents(v => !v) - setDeepSubagents(false) - } - }} - open={openSubagents} - t={t} - title="Subagents" - /> - {openSubagents && subagentBlock} - - )} + return ( + + + + {group.content} + + } + rails={rails} + t={t} + /> + {group.details.map((detail, detailIndex) => ( + + ))} + {hasInlineSubagents ? renderSubagentList(childRails) : null} + + ) + })} + + ) + }) + } - {hasMeta && ( - <> - shift ? expandAll() : setOpenMeta(v => !v)} - open={openMeta} - t={t} - title="Activity" - tone={metaTone} - /> - {openMeta && metaBlock} - - )} + if (hasSubagents && !inlineDelegateKey) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + setDeepSubagents(true) + } else { + setOpenSubagents(v => !v) + setDeepSubagents(false) + } + }} + open={detailsMode === 'expanded' || openSubagents} + t={t} + title="Subagents" + /> + ), + key: 'subagents', + open: detailsMode === 'expanded' || openSubagents, + render: renderSubagentList + }) + } - {totalBlock} + if (hasMeta) { + sections.push({ + header: ( + { + if (shift) { + expandAll() + } else { + setOpenMeta(v => !v) + } + }} + open={detailsMode === 'expanded' || openMeta} + t={t} + title="Activity" + tone={metaTone} + /> + ), + key: 'meta', + open: detailsMode === 'expanded' || openMeta, + render: rails => ( + + {meta.map((row, index) => ( + + ))} + + ) + }) + } + + const topCount = sections.length + (totalTokensLabel ? 1 : 0) + + return ( + + {sections.map((section, index) => ( + + {section.render} + + ))} + {totalTokensLabel ? ( + + Σ + {totalTokensLabel} + + } + dimColor + t={t} + /> + ) : null} ) }) From cb31732c4f3a2326e385194184b2215744bf6801 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Apr 2026 23:29:00 -0500 Subject: [PATCH 116/157] chore: uptick --- .../src/__tests__/createSlashHandler.test.ts | 123 ++ ui-tui/src/app.tsx | 15 +- ui-tui/src/app/createSlashHandler.ts | 1297 +---------------- .../src/app/slash/createSlashCoreHandler.ts | 328 +++++ ui-tui/src/app/slash/createSlashOpsHandler.ts | 372 +++++ .../app/slash/createSlashSessionHandler.ts | 382 +++++ ui-tui/src/app/slash/shared.ts | 48 + ui-tui/src/components/thinking.tsx | 8 +- ui-tui/src/hooks/useVirtualHistory.ts | 1 + ui-tui/vitest.config.ts | 7 + 10 files changed, 1344 insertions(+), 1237 deletions(-) create mode 100644 ui-tui/src/__tests__/createSlashHandler.test.ts create mode 100644 ui-tui/src/app/slash/createSlashCoreHandler.ts create mode 100644 ui-tui/src/app/slash/createSlashOpsHandler.ts create mode 100644 ui-tui/src/app/slash/createSlashSessionHandler.ts create mode 100644 ui-tui/src/app/slash/shared.ts create mode 100644 ui-tui/vitest.config.ts diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts new file mode 100644 index 0000000000..b7f8955398 --- /dev/null +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createSlashHandler } from '../app/createSlashHandler.js' +import { getOverlayState, resetOverlayState } from '../app/overlayStore.js' +import { getUiState, resetUiState } from '../app/uiStore.js' + +describe('createSlashHandler', () => { + beforeEach(() => { + resetOverlayState() + resetUiState() + }) + + it('opens the resume picker locally', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/resume')).toBe(true) + expect(getOverlayState().picker).toBe(true) + }) + + it('cycles details mode and persists it', async () => { + const ctx = buildCtx() + + expect(getUiState().detailsMode).toBe('collapsed') + expect(createSlashHandler(ctx)('/details toggle')).toBe(true) + expect(getUiState().detailsMode).toBe('expanded') + expect(ctx.gateway.rpc).toHaveBeenCalledWith('config.set', { + key: 'details_mode', + value: 'expanded' + }) + expect(ctx.transcript.sys).toHaveBeenCalledWith('details: expanded') + }) + + it('shows tool enable usage when names are missing', () => { + const ctx = buildCtx() + + expect(createSlashHandler(ctx)('/tools enable')).toBe(true) + expect(ctx.transcript.sys).toHaveBeenNthCalledWith(1, 'usage: /tools enable [name ...]') + expect(ctx.transcript.sys).toHaveBeenNthCalledWith(2, 'built-in toolset: /tools enable web') + expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue') + }) + + it('resolves unique local aliases through the catalog', () => { + const ctx = buildCtx({ + local: { + catalog: { + canon: { + '/h': '/help', + '/help': '/help' + } + } + } + }) + + expect(createSlashHandler(ctx)('/h')).toBe(true) + expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array)) + }) +}) + +const buildCtx = (overrides: Partial = {}): Ctx => ({ + ...overrides, + composer: { ...buildComposer(), ...overrides.composer }, + gateway: { ...buildGateway(), ...overrides.gateway }, + local: { ...buildLocal(), ...overrides.local }, + session: { ...buildSession(), ...overrides.session }, + transcript: { ...buildTranscript(), ...overrides.transcript }, + voice: { ...buildVoice(), ...overrides.voice } +}) + +const buildComposer = () => ({ + enqueue: vi.fn(), + hasSelection: false, + paste: vi.fn(), + queueRef: { current: [] as string[] }, + selection: { copySelection: vi.fn(() => '') }, + setInput: vi.fn() +}) + +const buildGateway = () => ({ + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn(() => Promise.resolve({})) + }, + rpc: vi.fn(() => Promise.resolve({})) +}) + +const buildLocal = () => ({ + catalog: null, + getHistoryItems: vi.fn(() => []), + getLastUserMsg: vi.fn(() => ''), + maybeWarn: vi.fn() +}) + +const buildSession = () => ({ + closeSession: vi.fn(() => Promise.resolve(null)), + die: vi.fn(), + guardBusySessionSwitch: vi.fn(() => false), + newSession: vi.fn(), + resetVisibleHistory: vi.fn(), + resumeById: vi.fn(), + setSessionStartedAt: vi.fn() +}) + +const buildTranscript = () => ({ + page: vi.fn(), + panel: vi.fn(), + send: vi.fn(), + setHistoryItems: vi.fn(), + sys: vi.fn(), + trimLastExchange: vi.fn(items => items) +}) + +const buildVoice = () => ({ + setVoiceEnabled: vi.fn() +}) + +interface Ctx { + composer: ReturnType + gateway: ReturnType + local: ReturnType + session: ReturnType + transcript: ReturnType + voice: ReturnType +} diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 947af602a9..ee1a709780 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -235,16 +235,13 @@ export function App({ gw }: { gw: GatewayClient }) { [sys] ) - const maybeGoodVibes = useCallback( - (text: string) => { - if (!GOOD_VIBES_RE.test(text)) { - return - } + const maybeGoodVibes = useCallback((text: string) => { + if (!GOOD_VIBES_RE.test(text)) { + return + } - setGoodVibesTick(v => v + 1) - }, - [] - ) + setGoodVibesTick(v => v + 1) + }, []) const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { const display = cfg?.config?.display ?? {} diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index eb5a03583a..b208043805 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,1234 +1,89 @@ -import { HOTKEYS } from '../constants.js' -import type { - BackgroundStartResponse, - SessionHistoryResponse, - SlashExecResponse, - ToolsConfigureResponse, - ToolsListResponse, - ToolsShowResponse -} from '../gatewayTypes.js' -import { writeOsc52Clipboard } from '../lib/osc52.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import { fmtK } from '../lib/text.js' -import type { DetailsMode, PanelSection } from '../types.js' -import { imageTokenMeta, introMsg, nextDetailsMode, parseDetailsMode, toTranscriptMessages } from './helpers.js' import type { SlashHandlerContext } from './interfaces.js' -import { patchOverlayState } from './overlayStore.js' -import { getUiState, patchUiState } from './uiStore.js' - -const FORTUNES = [ - 'you are one clean refactor away from clarity', - 'a tiny rename today prevents a huge bug tomorrow', - 'your next commit message will be immaculate', - 'the edge case you are ignoring is already solved in your head', - 'minimal diff, maximal calm', - 'today favors bold deletions over new abstractions', - 'the right helper is already in your codebase', - 'you will ship before overthinking catches up', - 'tests are about to save your future self', - 'your instincts are correctly suspicious of that one branch' -] - -const LEGENDARY_FORTUNES = [ - 'legendary drop: one-line fix, first try', - 'legendary drop: every flaky test passes cleanly', - 'legendary drop: your diff teaches by itself' -] - -const hash = (input: string) => { - let out = 2166136261 - - for (let i = 0; i < input.length; i++) { - out ^= input.charCodeAt(i) - out = Math.imul(out, 16777619) - } - - return out >>> 0 -} - -const fortuneFromScore = (score: number) => { - const rare = score % 20 === 0 - const bag = rare ? LEGENDARY_FORTUNES : FORTUNES - - return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` -} - -const randomFortune = () => { - const score = Math.floor(Math.random() * 0x7fffffff) - - return fortuneFromScore(score) -} - -const dailyFortune = (sid: string | null) => { - const seed = `${sid || 'anon'}|${new Date().toDateString()}` - const score = hash(seed) - - return fortuneFromScore(score) -} +import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js' +import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js' +import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js' +import { createSlashShared, parseSlashCommand } from './slash/shared.js' +import { getUiState } from './uiStore.js' export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { - const { enqueue, hasSelection, paste, queueRef, selection, setInput } = ctx.composer - const { gw, rpc } = ctx.gateway - const { catalog, getHistoryItems, getLastUserMsg, maybeWarn } = ctx.local - - const { - closeSession, - die, - guardBusySessionSwitch, - newSession, - resetVisibleHistory, - resumeById, - setSessionStartedAt - } = ctx.session - - const { page, panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript - const { setVoiceEnabled } = ctx.voice - - const showSlashOutput = (title: string, command: string) => { - gw.request('slash.exec', { command, session_id: getUiState().sid }) - .then(r => { - const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' - const lines = text.split('\n').filter(Boolean) - - if (lines.length > 2 || text.length > 180) { - page(text, title) - } else { - sys(text) - } - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } + const { gw } = ctx.gateway + const { catalog } = ctx.local + const { send, sys } = ctx.transcript + const shared = createSlashShared({ ...ctx.transcript, gw }) + const handleCore = createSlashCoreHandler(ctx) + const handleSession = createSlashSessionHandler(ctx, shared) + const handleOps = createSlashOpsHandler(ctx) const handler = (cmd: string): boolean => { const ui = getUiState() - const detailsMode = ui.detailsMode - const sid = ui.sid - const [rawName, ...rest] = cmd.slice(1).split(/\s+/) - const name = rawName.toLowerCase() - const arg = rest.join(' ') + const parsed = { ...parseSlashCommand(cmd), sid: ui.sid, ui } + const argTail = parsed.arg ? ` ${parsed.arg}` : '' - switch (name) { - case 'help': { - const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({ - title: catName, - rows: pairs - })) - - if (catalog?.skillCount) { - sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) - } - - sections.push({ - title: 'TUI', - rows: [ - ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], - ['/fortune [random|daily]', 'show a random or daily local fortune'] - ] - }) - - sections.push({ title: 'Hotkeys', rows: HOTKEYS }) - - panel('Commands', sections) - - return true - } - - case 'quit': - - case 'exit': - - case 'q': - die() - - return true - - case 'clear': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - patchUiState({ status: 'forging session…' }) - newSession() - - return true - - case 'new': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - patchUiState({ status: 'forging session…' }) - newSession('new session started') - - return true - - case 'resume': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - if (arg) { - resumeById(arg) - } else { - patchOverlayState({ picker: true }) - } - - return true - - case 'compact': - if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { - sys('usage: /compact [on|off|toggle]') - - return true - } - - { - const mode = arg.trim().toLowerCase() - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact - - patchUiState({ compact: next }) - rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) - } - - return true - - case 'details': - - case 'detail': - if (!arg) { - rpc('config.get', { key: 'details_mode' }) - .then((r: any) => { - const mode = parseDetailsMode(r?.value) ?? detailsMode - patchUiState({ detailsMode: mode }) - sys(`details: ${mode}`) - }) - .catch(() => sys(`details: ${detailsMode}`)) - - return true - } - - { - const mode = arg.trim().toLowerCase() - - if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { - sys('usage: /details [hidden|collapsed|expanded|cycle]') - - return true - } - - const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(detailsMode) : (mode as DetailsMode) - patchUiState({ detailsMode: next }) - rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) - sys(`details: ${next}`) - } - - return true - - case 'fortune': - if (!arg || arg.trim().toLowerCase() === 'random') { - sys(randomFortune()) - - return true - } - - if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) { - sys(dailyFortune(sid)) - - return true - } - - sys('usage: /fortune [random|daily]') - - return true - case 'copy': { - if (!arg && hasSelection) { - const copied = selection.copySelection() - - if (copied) { - sys('copied selection') - - return true - } - } - - const all = getHistoryItems().filter((m: any) => m.role === 'assistant') - - if (arg && Number.isNaN(parseInt(arg, 10))) { - sys('usage: /copy [number]') - - return true - } - - const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] - - if (!target) { - sys('nothing to copy') - - return true - } - - writeOsc52Clipboard(target.text) - sys('sent OSC52 copy sequence (terminal support required)') - - return true - } - - case 'paste': - if (!arg) { - paste() - - return true - } - - sys('usage: /paste') - - return true - case 'logs': { - const logText = gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) - logText ? page(logText, 'Logs') : sys('no gateway logs') - - return true - } - - case 'statusbar': - - case 'sb': - if (arg && !['on', 'off', 'toggle'].includes(arg.trim().toLowerCase())) { - sys('usage: /statusbar [on|off|toggle]') - - return true - } - - { - const mode = arg.trim().toLowerCase() - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar - - patchUiState({ statusBar: next }) - rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) - } - - return true - - case 'queue': - if (!arg) { - sys(`${queueRef.current.length} queued message(s)`) - - return true - } - - enqueue(arg) - sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) - - return true - - case 'undo': - if (!sid) { - sys('nothing to undo') - - return true - } - - rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - if (r.removed > 0) { - setHistoryItems((prev: any[]) => trimLastExchange(prev)) - sys(`undid ${r.removed} messages`) - } else { - sys('nothing to undo') - } - }) - - return true - case 'retry': { - const lastUserMsg = getLastUserMsg() - - if (!lastUserMsg) { - sys('nothing to retry') - - return true - } - - if (sid) { - rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - if (r.removed <= 0) { - sys('nothing to retry') - - return - } - - setHistoryItems((prev: any[]) => trimLastExchange(prev)) - send(lastUserMsg) - }) - - return true - } - - send(lastUserMsg) - - return true - } - - case 'background': - - case 'bg': - if (!arg) { - sys('/background ') - - return true - } - - rpc('prompt.background', { session_id: sid, text: arg }).then(r => { - const taskId = r?.task_id - - if (!taskId) { - return - } - - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) })) - sys(`bg ${taskId} started`) - }) - - return true - - case 'btw': - if (!arg) { - sys('/btw ') - - return true - } - - rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => { - if (!r) { - return - } - - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) - sys('btw running…') - }) - - return true - - case 'model': - if (guardBusySessionSwitch('change models')) { - return true - } - - if (!arg) { - patchOverlayState({ modelPicker: true }) - } else { - rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { - if (!r) { - return - } - - if (!r.value) { - sys('error: invalid response: model switch') - - return - } - - sys(`model → ${r.value}`) - maybeWarn(r) - patchUiState(state => ({ - ...state, - info: state.info ? { ...state.info, model: r.value } : { model: r.value, skills: {}, tools: {} } - })) - }) - } - - return true - - case 'image': - rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { - if (!r) { - return - } - - const meta = imageTokenMeta(r) - sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - - if (r?.remainder) { - setInput(r.remainder) - } - }) - - return true - - case 'provider': - gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => { - page( - r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', - 'Provider' - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - case 'skin': - if (arg) { - rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`skin → ${r.value}`) - }) - } else { - rpc('config.get', { key: 'skin' }).then((r: any) => { - if (!r) { - return - } - - sys(`skin: ${r.value || 'default'}`) - }) - } - - return true - - case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { - if (!r) { - return - } - - sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - }) - - return true - - case 'reasoning': - if (!arg) { - rpc('config.get', { key: 'reasoning' }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) - }) - } else { - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`reasoning: ${r.value}`) - }) - } - - return true - - case 'verbose': - rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { - if (!r?.value) { - return - } - - sys(`verbose: ${r.value}`) - }) - - return true - - case 'personality': - if (arg) { - rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { - if (!r) { - return - } - - if (r.history_reset) { - resetVisibleHistory(r.info ?? null) - } - - sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) - maybeWarn(r) - }) - } else { - gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => { - panel('Personality', [ - { - text: r?.warning - ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` - : r?.output || '(no output)' - } - ]) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } - - return true - - case 'compress': - rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { - if (!r) { - return - } - - if (Array.isArray(r.messages)) { - const resumed = toTranscriptMessages(r.messages) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - } - - if (r.info) { - patchUiState({ info: r.info }) - } - - if (r.usage) { - patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) - } - - if ((r.removed ?? 0) <= 0) { - sys('nothing to compress') - - return - } - - sys(`compressed ${r.removed} messages${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`) - }) - - return true - - case 'stop': - rpc('process.stop', {}).then((r: any) => { - if (!r) { - return - } - - sys(`killed ${r.killed ?? 0} registered process(es)`) - }) - - return true - - case 'branch': - case 'fork': { - const prevSid = sid - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (r?.session_id) { - void closeSession(prevSid) - patchUiState({ sid: r.session_id }) - setSessionStartedAt(Date.now()) - setHistoryItems([]) - sys(`branched → ${r.title}`) - } - }) - - return true - } - - case 'reload-mcp': - - case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - sys('MCP reloaded') - }) - - return true - - case 'fast': - showSlashOutput('Fast', cmd.slice(1)) - - return true - - case 'debug': - showSlashOutput('Debug', cmd.slice(1)) - - return true - - case 'snapshot': - showSlashOutput('Snapshot', cmd.slice(1)) - - return true - - case 'platforms': - showSlashOutput('Platforms', cmd.slice(1)) - - return true - - case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { - if (!r) { - return - } - - sys(`title: ${r.title || '(none)'}`) - }) - - return true - - case 'usage': - rpc('session.usage', { session_id: sid }).then((r: any) => { - if (r) { - patchUiState({ - usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 } - }) - } - - if (!r?.calls) { - sys('no API calls yet') - - return - } - - const f = (v: number) => (v ?? 0).toLocaleString() - - const cost = - r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null - - const rows: [string, string][] = [ - ['Model', r.model ?? ''], - ['Input tokens', f(r.input)], - ['Cache read tokens', f(r.cache_read)], - ['Cache write tokens', f(r.cache_write)], - ['Output tokens', f(r.output)], - ['Total tokens', f(r.total)], - ['API calls', f(r.calls)] - ] - - if (cost) { - rows.push(['Cost', cost]) - } - - const sections: PanelSection[] = [{ rows }] - - if (r.context_max) { - sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) - } - - if (r.compressions) { - sections.push({ text: `Compressions: ${r.compressions}` }) - } - - panel('Usage', sections) - }) - - return true - - case 'save': - rpc('session.save', { session_id: sid }).then((r: any) => { - if (!r?.file) { - return - } - - sys(`saved: ${r.file}`) - }) - - return true - - case 'history': - rpc('session.history', { session_id: sid }).then(r => { - if (typeof r?.count !== 'number') { - return - } - - if (!r.messages?.length) { - sys(`${r.count} messages`) - - return - } - - const text = r.messages - .map((msg, index) => { - if (msg.role === 'tool') { - return `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim() - } - - return `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim() - }) - .join('\n\n') - - page(text, `History (${r.count})`) - }) - - return true - - case 'profile': - rpc('config.get', { key: 'profile' }).then((r: any) => { - if (!r) { - return - } - - const text = r.display || r.home || '(unknown profile)' - const lines = text.split('\n').filter(Boolean) - - if (lines.length <= 2) { - panel('Profile', [{ text }]) - } else { - page(text, 'Profile') - } - }) - - return true - - case 'voice': - rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { - if (!r) { - return - } - - setVoiceEnabled(!!r?.enabled) - sys(`voice: ${r.enabled ? 'on' : 'off'}`) - }) - - return true - - case 'insights': - rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { - if (!r) { - return - } - - panel('Insights', [ - { - rows: [ - ['Period', `${r.days} days`], - ['Sessions', `${r.sessions}`], - ['Messages', `${r.messages}`] - ] - } - ]) - }) - - return true - case 'rollback': { - const [sub, ...rArgs] = (arg || 'list').split(/\s+/) - - if (!sub || sub === 'list') { - rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r) { - return - } - - if (!r.checkpoints?.length) { - return sys('no checkpoints') - } - - panel('Checkpoints', [ - { - rows: r.checkpoints.map( - (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] - ) - } - ]) - }) - } else { - const hash = sub === 'restore' || sub === 'diff' ? rArgs[0] : sub - - const filePath = - sub === 'restore' || sub === 'diff' ? rArgs.slice(1).join(' ').trim() : rArgs.join(' ').trim() - - rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { - session_id: sid, - hash, - ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => { - if (!r) { - return - } - - sys(r.rendered || r.diff || r.message || 'done') - }) - } - - return true - } - - case 'browser': { - const [act, ...bArgs] = (arg || 'status').split(/\s+/) - rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => { - if (!r) { - return - } - - sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - }) - - return true - } - - case 'plugins': - rpc('plugins.list', {}).then((r: any) => { - if (!r) { - return - } - - if (!r.plugins?.length) { - return sys('no plugins') - } - - panel('Plugins', [ - { - items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) - } - ]) - }) - - return true - case 'skills': { - const [sub, ...sArgs] = (arg || '').split(/\s+/).filter(Boolean) - - if (!sub || sub === 'list') { - rpc('skills.manage', { action: 'list' }).then((r: any) => { - if (!r) { - return - } - - const sk = r.skills as Record | undefined - - if (!sk || !Object.keys(sk).length) { - return sys('no skills installed') - } - - panel( - 'Installed Skills', - Object.entries(sk).map(([cat, names]) => ({ - title: cat, - items: names as string[] - })) - ) - }) - - return true - } - - if (sub === 'browse') { - const pg = parseInt(sArgs[0] ?? '1', 10) || 1 - rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => { - if (!r) { - return - } - - if (!r.items?.length) { - return sys('no skills found in the hub') - } - - const sections: PanelSection[] = [ - { - rows: r.items.map( - (s: any) => - [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [ - string, - string - ] - ) - } - ] - - if (r.page < r.total_pages) { - sections.push({ text: `/skills browse ${r.page + 1} → next page` }) - } - - if (r.page > 1) { - sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) - } - - panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) - }) - - return true - } - - gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` - : r?.output || '/skills: no output' - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - case 'agents': - - case 'tasks': - rpc('agents.list', {}) - .then((r: any) => { - if (!r) { - return - } - - const procs = r.processes ?? [] - const running = procs.filter((p: any) => p.status === 'running') - const finished = procs.filter((p: any) => p.status !== 'running') - const sections: PanelSection[] = [] - - if (running.length) { - sections.push({ - title: `Running (${running.length})`, - rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } - - if (finished.length) { - sections.push({ - title: `Finished (${finished.length})`, - rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - } - - if (!sections.length) { - sections.push({ text: 'No active processes' }) - } - - panel('Agents', sections) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - case 'cron': - if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }) - .then((r: any) => { - if (!r) { - return - } - - const jobs = r.jobs ?? [] - - if (!jobs.length) { - return sys('no scheduled jobs') - } - - panel('Cron', [ - { - rows: jobs.map( - (j: any) => - [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string] - ) - } - ]) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } else { - gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - } - - return true - - case 'config': - rpc('config.show', {}) - .then((r: any) => { - if (!r) { - return - } - - panel( - 'Config', - (r.sections ?? []).map((s: any) => ({ - title: s.title, - rows: s.rows - })) - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - case 'tools': { - const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) - - if (!subcommand) { - rpc('tools.show', { session_id: sid }) - .then(r => { - if (!r?.sections?.length) { - return sys('no tools') - } - - panel( - `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, - r.sections.map(section => ({ - title: section.name, - rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]) - })) - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - if (subcommand === 'list') { - rpc('tools.list', { session_id: sid }) - .then(r => { - if (!r?.toolsets?.length) { - return sys('no tools') - } - - panel( - 'Tools', - r.toolsets.map(ts => ({ - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, - items: ts.tools - })) - ) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - if (subcommand === 'disable' || subcommand === 'enable') { - if (!names.length) { - sys(`usage: /tools ${subcommand} [name ...]`) - sys(`built-in toolset: /tools ${subcommand} web`) - sys(`MCP tool: /tools ${subcommand} github:create_issue`) - - return true - } - - rpc('tools.configure', { - action: subcommand, - names, - session_id: sid - }) - .then(r => { - if (!r) { - return - } - - if (r.info) { - setSessionStartedAt(Date.now()) - resetVisibleHistory(r.info) - } - - if (r.changed?.length) { - sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) - } - - if (r.unknown?.length) { - sys(`unknown toolsets: ${r.unknown.join(', ')}`) - } - - if (r.missing_servers?.length) { - sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) - } - - if (r.reset) { - sys('session reset. new tool configuration is active.') - } - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - } - - sys('usage: /tools [list|disable|enable] ...') - - return true - } - - case 'toolsets': - rpc('toolsets.list', { session_id: sid }) - .then((r: any) => { - if (!r) { - return - } - - if (!r.toolsets?.length) { - return sys('no toolsets') - } - - panel('Toolsets', [ - { - rows: r.toolsets.map( - (ts: any) => - [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ - string, - string - ] - ) - } - ]) - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - - return true - - default: - if (catalog?.canon) { - const needle = `/${name}`.toLowerCase() - - const matches = [ - ...new Set( - Object.entries(catalog.canon) - .filter(([alias]) => alias.startsWith(needle)) - .map(([, canon]) => canon) - ) - ] - - if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) { - return handler(`${matches[0]}${arg ? ' ' + arg : ''}`) - } - - if (matches.length > 1) { - sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`) - - return true - } - } - - gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || `/${name}: no output`}` - : r?.output || `/${name}: no output` - ) - }) - .catch(() => { - gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid }) - .then((raw: any) => { - const d = asRpcResult(raw) - - if (!d?.type) { - sys('error: invalid response: command.dispatch') - - return - } - - if (d.type === 'exec') { - sys(d.output || '(no output)') - } else if (d.type === 'alias') { - handler(`/${d.target}${arg ? ' ' + arg : ''}`) - } else if (d.type === 'plugin') { - sys(d.output || '(no output)') - } else if (d.type === 'skill') { - sys(`⚡ loading skill: ${d.name}`) - - if (typeof d.message === 'string' && d.message.trim()) { - send(d.message) - } else { - sys(`/${name}: skill payload missing message`) - } - } - }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) - }) - - return true + if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) { + return true } + + if (catalog?.canon) { + const needle = `/${parsed.name}`.toLowerCase() + + const matches = [ + ...new Set( + Object.entries(catalog.canon) + .filter(([alias]) => alias.startsWith(needle)) + .map(([, canon]) => canon) + ) + ] + + if (matches.length === 1 && matches[0]!.toLowerCase() !== needle) { + return handler(`${matches[0]}${argTail}`) + } + + if (matches.length > 1) { + sys(`ambiguous command: ${matches.slice(0, 6).join(', ')}${matches.length > 6 ? ', …' : ''}`) + + return true + } + } + + gw.request('slash.exec', { command: cmd.slice(1), session_id: ui.sid }) + .then((r: any) => { + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || `/${parsed.name}: no output`}` + : r?.output || `/${parsed.name}: no output` + ) + }) + .catch(() => { + gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: ui.sid }) + .then((raw: any) => { + const d = asRpcResult(raw) + + if (!d?.type) { + sys('error: invalid response: command.dispatch') + + return + } + + if (d.type === 'exec' || d.type === 'plugin') { + sys(d.output || '(no output)') + } else if (d.type === 'alias') { + handler(`/${d.target}${argTail}`) + } else if (d.type === 'skill') { + sys(`⚡ loading skill: ${d.name}`) + + if (typeof d.message === 'string' && d.message.trim()) { + send(d.message) + } else { + sys(`/${parsed.name}: skill payload missing message`) + } + } + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + + return true } return handler diff --git a/ui-tui/src/app/slash/createSlashCoreHandler.ts b/ui-tui/src/app/slash/createSlashCoreHandler.ts new file mode 100644 index 0000000000..cb980248a4 --- /dev/null +++ b/ui-tui/src/app/slash/createSlashCoreHandler.ts @@ -0,0 +1,328 @@ +import { HOTKEYS } from '../../constants.js' +import { writeOsc52Clipboard } from '../../lib/osc52.js' +import type { DetailsMode, PanelSection } from '../../types.js' +import { nextDetailsMode, parseDetailsMode } from '../helpers.js' +import type { SlashHandlerContext } from '../interfaces.js' +import { patchOverlayState } from '../overlayStore.js' +import { patchUiState } from '../uiStore.js' + +const FORTUNES = [ + 'you are one clean refactor away from clarity', + 'a tiny rename today prevents a huge bug tomorrow', + 'your next commit message will be immaculate', + 'the edge case you are ignoring is already solved in your head', + 'minimal diff, maximal calm', + 'today favors bold deletions over new abstractions', + 'the right helper is already in your codebase', + 'you will ship before overthinking catches up', + 'tests are about to save your future self', + 'your instincts are correctly suspicious of that one branch' +] + +const LEGENDARY_FORTUNES = [ + 'legendary drop: one-line fix, first try', + 'legendary drop: every flaky test passes cleanly', + 'legendary drop: your diff teaches by itself' +] + +const hash = (input: string) => { + let out = 2166136261 + + for (let i = 0; i < input.length; i++) { + out ^= input.charCodeAt(i) + out = Math.imul(out, 16777619) + } + + return out >>> 0 +} + +const fortuneFromScore = (score: number) => { + const rare = score % 20 === 0 + const bag = rare ? LEGENDARY_FORTUNES : FORTUNES + + return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` +} + +const randomFortune = () => fortuneFromScore(Math.floor(Math.random() * 0x7fffffff)) + +const dailyFortune = (sid: null | string) => fortuneFromScore(hash(`${sid || 'anon'}|${new Date().toDateString()}`)) + +export function createSlashCoreHandler(ctx: SlashHandlerContext) { + const { enqueue, hasSelection, paste, queueRef, selection } = ctx.composer + const { catalog, getHistoryItems, getLastUserMsg } = ctx.local + const { guardBusySessionSwitch, newSession, resumeById } = ctx.session + const { panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript + + return ({ arg, name, sid, ui }: SlashCommand) => { + switch (name) { + case 'help': { + const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({ + title: catName, + rows: pairs + })) + + if (catalog?.skillCount) { + sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) + } + + sections.push({ + title: 'TUI', + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ] + }) + sections.push({ title: 'Hotkeys', rows: HOTKEYS }) + panel('Commands', sections) + + return true + } + + case 'quit': + + case 'exit': + + case 'q': + ctx.session.die() + + return true + + case 'clear': + + case 'new': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + + patchUiState({ status: 'forging session…' }) + newSession(name === 'new' ? 'new session started' : undefined) + + return true + + case 'resume': + if (guardBusySessionSwitch('switch sessions')) { + return true + } + + arg ? resumeById(arg) : patchOverlayState({ picker: true }) + + return true + case 'compact': { + const mode = arg.trim().toLowerCase() + + if (arg && !['on', 'off', 'toggle'].includes(mode)) { + sys('usage: /compact [on|off|toggle]') + + return true + } + + const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact + + patchUiState({ compact: next }) + ctx.gateway.rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) + queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) + + return true + } + + case 'details': + + case 'detail': + if (!arg) { + ctx.gateway + .rpc('config.get', { key: 'details_mode' }) + .then((r: any) => { + const mode = parseDetailsMode(r?.value) ?? ui.detailsMode + + patchUiState({ detailsMode: mode }) + sys(`details: ${mode}`) + }) + .catch(() => sys(`details: ${ui.detailsMode}`)) + + return true + } + + { + const mode = arg.trim().toLowerCase() + + if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { + sys('usage: /details [hidden|collapsed|expanded|cycle]') + + return true + } + + const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) + + patchUiState({ detailsMode: next }) + ctx.gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) + sys(`details: ${next}`) + } + + return true + + case 'fortune': + if (!arg || arg.trim().toLowerCase() === 'random') { + sys(randomFortune()) + + return true + } + + if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) { + sys(dailyFortune(sid)) + + return true + } + + sys('usage: /fortune [random|daily]') + + return true + case 'copy': { + if (!arg && hasSelection) { + const copied = selection.copySelection() + + if (copied) { + sys('copied selection') + + return true + } + } + + if (arg && Number.isNaN(parseInt(arg, 10))) { + sys('usage: /copy [number]') + + return true + } + + const all = getHistoryItems().filter((m: any) => m.role === 'assistant') + const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] + + if (!target) { + sys('nothing to copy') + + return true + } + + writeOsc52Clipboard(target.text) + sys('sent OSC52 copy sequence (terminal support required)') + + return true + } + + case 'paste': + if (!arg) { + paste() + + return true + } + + sys('usage: /paste') + + return true + case 'logs': { + const logText = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) + + logText ? ctx.transcript.page(logText, 'Logs') : sys('no gateway logs') + + return true + } + + case 'statusbar': + case 'sb': { + const mode = arg.trim().toLowerCase() + + if (arg && !['on', 'off', 'toggle'].includes(mode)) { + sys('usage: /statusbar [on|off|toggle]') + + return true + } + + const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar + + patchUiState({ statusBar: next }) + ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) + queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) + + return true + } + + case 'queue': + if (!arg) { + sys(`${queueRef.current.length} queued message(s)`) + + return true + } + + enqueue(arg) + sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + + return true + + case 'undo': + if (!sid) { + sys('nothing to undo') + + return true + } + + ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (r.removed > 0) { + setHistoryItems((prev: any[]) => trimLastExchange(prev)) + sys(`undid ${r.removed} messages`) + } else { + sys('nothing to undo') + } + }) + + return true + case 'retry': { + const lastUserMsg = getLastUserMsg() + + if (!lastUserMsg) { + sys('nothing to retry') + + return true + } + + if (!sid) { + send(lastUserMsg) + + return true + } + + ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (r.removed <= 0) { + sys('nothing to retry') + + return + } + + setHistoryItems((prev: any[]) => trimLastExchange(prev)) + send(lastUserMsg) + }) + + return true + } + } + + return false + } +} + +interface SlashCommand { + arg: string + name: string + sid: null | string + ui: { + compact: boolean + detailsMode: DetailsMode + statusBar: boolean + } +} diff --git a/ui-tui/src/app/slash/createSlashOpsHandler.ts b/ui-tui/src/app/slash/createSlashOpsHandler.ts new file mode 100644 index 0000000000..4627244e3b --- /dev/null +++ b/ui-tui/src/app/slash/createSlashOpsHandler.ts @@ -0,0 +1,372 @@ +import type { ToolsConfigureResponse, ToolsListResponse, ToolsShowResponse } from '../../gatewayTypes.js' +import { rpcErrorMessage } from '../../lib/rpc.js' +import type { PanelSection } from '../../types.js' +import type { SlashHandlerContext } from '../interfaces.js' + +import type { ParsedSlashCommand } from './shared.js' + +export function createSlashOpsHandler(ctx: SlashHandlerContext) { + const { rpc } = ctx.gateway + const { resetVisibleHistory, setSessionStartedAt } = ctx.session + const { panel, sys } = ctx.transcript + + return ({ arg, cmd, name, sid }: OpsSlashCommand) => { + switch (name) { + case 'rollback': { + const [sub, ...rest] = (arg || 'list').split(/\s+/) + + if (!sub || sub === 'list') { + rpc('rollback.list', { session_id: sid }).then((r: any) => { + if (!r) { + return + } + + if (!r.checkpoints?.length) { + sys('no checkpoints') + + return + } + + panel('Checkpoints', [ + { + rows: r.checkpoints.map( + (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] + ) + } + ]) + }) + + return true + } + + const hash = sub === 'restore' || sub === 'diff' ? rest[0] : sub + const filePath = (sub === 'restore' || sub === 'diff' ? rest.slice(1) : rest).join(' ').trim() + + rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { + session_id: sid, + hash, + ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) + }).then((r: any) => r && sys(r.rendered || r.diff || r.message || 'done')) + + return true + } + + case 'browser': { + const [action, ...rest] = (arg || 'status').split(/\s+/) + + rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then( + (r: any) => r && sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') + ) + + return true + } + + case 'plugins': + rpc('plugins.list', {}).then((r: any) => { + if (!r) { + return + } + + if (!r.plugins?.length) { + sys('no plugins') + + return + } + + panel('Plugins', [ + { + items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) + } + ]) + }) + + return true + case 'skills': { + const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean) + + if (!sub || sub === 'list') { + rpc('skills.manage', { action: 'list' }).then((r: any) => { + if (!r) { + return + } + + const skills = r.skills as Record | undefined + + if (!skills || !Object.keys(skills).length) { + sys('no skills installed') + + return + } + + panel( + 'Installed Skills', + Object.entries(skills).map(([title, items]) => ({ items, title })) + ) + }) + + return true + } + + if (sub === 'browse') { + const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 + + rpc('skills.manage', { action: 'browse', page: pageNumber }).then((r: any) => { + if (!r) { + return + } + + if (!r.items?.length) { + sys('no skills found in the hub') + + return + } + + const sections: PanelSection[] = [ + { + rows: r.items.map( + (s: any) => + [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [ + string, + string + ] + ) + } + ] + + if (r.page < r.total_pages) { + sections.push({ text: `/skills browse ${r.page + 1} → next page` }) + } + + if (r.page > 1) { + sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) + } + + panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) + }) + + return true + } + + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => + sys( + r?.warning + ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` + : r?.output || '/skills: no output' + ) + ) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + case 'agents': + + case 'tasks': + rpc('agents.list', {}) + .then((r: any) => { + if (!r) { + return + } + + const processes = r.processes ?? [] + const running = processes.filter((p: any) => p.status === 'running') + const finished = processes.filter((p: any) => p.status !== 'running') + const sections: PanelSection[] = [] + + running.length && + sections.push({ + title: `Running (${running.length})`, + rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + finished.length && + sections.push({ + title: `Finished (${finished.length})`, + rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) + }) + !sections.length && sections.push({ text: 'No active processes' }) + panel('Agents', sections) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + case 'cron': + if (!arg || arg === 'list') { + rpc('cron.manage', { action: 'list' }) + .then((r: any) => { + if (!r) { + return + } + + const jobs = r.jobs ?? [] + + if (!jobs.length) { + sys('no scheduled jobs') + + return + } + + panel('Cron', [ + { + rows: jobs.map( + (j: any) => + [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string] + ) + } + ]) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + } else { + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: sid }) + .then((r: any) => + sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') + ) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + } + + return true + + case 'config': + rpc('config.show', {}) + .then((r: any) => { + if (!r) { + return + } + + panel( + 'Config', + (r.sections ?? []).map((s: any) => ({ + title: s.title, + rows: s.rows + })) + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + case 'tools': { + const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) + + if (!subcommand) { + rpc('tools.show', { session_id: sid }) + .then(r => { + if (!r?.sections?.length) { + sys('no tools') + + return + } + + panel( + `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, + r.sections.map(section => ({ + title: section.name, + rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]) + })) + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + if (subcommand === 'list') { + rpc('tools.list', { session_id: sid }) + .then(r => { + if (!r?.toolsets?.length) { + sys('no tools') + + return + } + + panel( + 'Tools', + r.toolsets.map(ts => ({ + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, + items: ts.tools + })) + ) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + if (subcommand === 'disable' || subcommand === 'enable') { + if (!names.length) { + sys(`usage: /tools ${subcommand} [name ...]`) + sys(`built-in toolset: /tools ${subcommand} web`) + sys(`MCP tool: /tools ${subcommand} github:create_issue`) + + return true + } + + rpc('tools.configure', { + action: subcommand, + names, + session_id: sid + }) + .then(r => { + if (!r) { + return + } + + if (r.info) { + setSessionStartedAt(Date.now()) + resetVisibleHistory(r.info) + } + + r.changed?.length && sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) + r.unknown?.length && sys(`unknown toolsets: ${r.unknown.join(', ')}`) + r.missing_servers?.length && sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) + r.reset && sys('session reset. new tool configuration is active.') + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + sys('usage: /tools [list|disable|enable] ...') + + return true + } + + case 'toolsets': + rpc('toolsets.list', { session_id: sid }) + .then((r: any) => { + if (!r) { + return + } + + if (!r.toolsets?.length) { + sys('no toolsets') + + return + } + + panel('Toolsets', [ + { + rows: r.toolsets.map( + (ts: any) => + [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ + string, + string + ] + ) + } + ]) + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + } + + return false + } +} + +interface OpsSlashCommand extends ParsedSlashCommand { + sid: null | string +} diff --git a/ui-tui/src/app/slash/createSlashSessionHandler.ts b/ui-tui/src/app/slash/createSlashSessionHandler.ts new file mode 100644 index 0000000000..5fa817f5a1 --- /dev/null +++ b/ui-tui/src/app/slash/createSlashSessionHandler.ts @@ -0,0 +1,382 @@ +import type { BackgroundStartResponse, SessionHistoryResponse } from '../../gatewayTypes.js' +import { rpcErrorMessage } from '../../lib/rpc.js' +import { fmtK } from '../../lib/text.js' +import type { PanelSection } from '../../types.js' +import { imageTokenMeta, introMsg, toTranscriptMessages } from '../helpers.js' +import type { SlashHandlerContext } from '../interfaces.js' +import { patchOverlayState } from '../overlayStore.js' +import { patchUiState } from '../uiStore.js' + +import type { ParsedSlashCommand, SlashShared } from './shared.js' + +const SLASH_OUTPUT_PAGE: Record = { + debug: 'Debug', + fast: 'Fast', + platforms: 'Platforms', + snapshot: 'Snapshot' +} + +export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: SlashShared) { + const { setInput } = ctx.composer + const { gw, rpc } = ctx.gateway + const { maybeWarn } = ctx.local + const { closeSession, guardBusySessionSwitch, resetVisibleHistory, setSessionStartedAt } = ctx.session + const { page, panel, setHistoryItems, sys } = ctx.transcript + const { setVoiceEnabled } = ctx.voice + + return ({ arg, cmd, name, sid }: SessionSlashCommand) => { + const pageTitle = SLASH_OUTPUT_PAGE[name] + + if (pageTitle) { + shared.showSlashOutput(pageTitle, cmd.slice(1), sid) + + return true + } + + switch (name) { + case 'background': + + case 'bg': + if (!arg) { + sys('/background ') + + return true + } + + rpc('prompt.background', { session_id: sid, text: arg }).then(r => { + const taskId = r?.task_id + + if (!taskId) { + return + } + + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) })) + sys(`bg ${taskId} started`) + }) + + return true + + case 'btw': + if (!arg) { + sys('/btw ') + + return true + } + + rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => { + if (!r) { + return + } + + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) + sys('btw running…') + }) + + return true + + case 'model': + if (guardBusySessionSwitch('change models')) { + return true + } + + if (!arg) { + patchOverlayState({ modelPicker: true }) + + return true + } + + rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { + if (!r) { + return + } + + if (!r.value) { + sys('error: invalid response: model switch') + + return + } + + sys(`model → ${r.value}`) + maybeWarn(r) + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: r.value } : { model: r.value, skills: {}, tools: {} } + })) + }) + + return true + + case 'image': + rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { + if (!r) { + return + } + + const meta = imageTokenMeta(r) + + sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + r?.remainder && setInput(r.remainder) + }) + + return true + + case 'provider': + gw.request('slash.exec', { command: 'provider', session_id: sid }) + .then((r: any) => + page( + r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', + 'Provider' + ) + ) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + case 'skin': + if (arg) { + rpc('config.set', { key: 'skin', value: arg }).then((r: any) => r?.value && sys(`skin → ${r.value}`)) + } else { + rpc('config.get', { key: 'skin' }).then((r: any) => r && sys(`skin: ${r.value || 'default'}`)) + } + + return true + + case 'yolo': + rpc('config.set', { session_id: sid, key: 'yolo' }).then( + (r: any) => r && sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) + ) + + return true + + case 'reasoning': + if (!arg) { + rpc('config.get', { key: 'reasoning' }).then( + (r: any) => r?.value && sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + ) + } else { + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then( + (r: any) => r?.value && sys(`reasoning: ${r.value}`) + ) + } + + return true + + case 'verbose': + rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then( + (r: any) => r?.value && sys(`verbose: ${r.value}`) + ) + + return true + + case 'personality': + if (arg) { + rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { + if (!r) { + return + } + + r.history_reset && resetVisibleHistory(r.info ?? null) + sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) + maybeWarn(r) + }) + + return true + } + + gw.request('slash.exec', { command: 'personality', session_id: sid }) + .then((r: any) => + panel('Personality', [ + { + text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)' + } + ]) + ) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + + return true + + case 'compress': + rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { + if (!r) { + return + } + + Array.isArray(r.messages) && + setHistoryItems( + r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages) + ) + r.info && patchUiState({ info: r.info }) + r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) + + if ((r.removed ?? 0) <= 0) { + sys('nothing to compress') + + return + } + + sys(`compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}`) + }) + + return true + + case 'stop': + rpc('process.stop', {}).then((r: any) => r && sys(`killed ${r.killed ?? 0} registered process(es)`)) + + return true + + case 'branch': + case 'fork': { + const prevSid = sid + + rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { + if (!r?.session_id) { + return + } + + void closeSession(prevSid) + patchUiState({ sid: r.session_id }) + setSessionStartedAt(Date.now()) + setHistoryItems([]) + sys(`branched → ${r.title}`) + }) + + return true + } + + case 'reload-mcp': + + case 'reload_mcp': + rpc('reload.mcp', { session_id: sid }).then((r: any) => r && sys('MCP reloaded')) + + return true + + case 'title': + rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then( + (r: any) => r && sys(`title: ${r.title || '(none)'}`) + ) + + return true + + case 'usage': + rpc('session.usage', { session_id: sid }).then((r: any) => { + if (r) { + patchUiState({ + usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 } + }) + } + + if (!r?.calls) { + sys('no API calls yet') + + return + } + + const f = (v: number) => (v ?? 0).toLocaleString() + const cost = + r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + const sections: PanelSection[] = [{ rows }] + + cost && rows.push(['Cost', cost]) + r.context_max && + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + r.compressions && sections.push({ text: `Compressions: ${r.compressions}` }) + panel('Usage', sections) + }) + + return true + + case 'save': + rpc('session.save', { session_id: sid }).then((r: any) => r?.file && sys(`saved: ${r.file}`)) + + return true + + case 'history': + rpc('session.history', { session_id: sid }).then(r => { + if (typeof r?.count !== 'number') { + return + } + + if (!r.messages?.length) { + sys(`${r.count} messages`) + + return + } + + page( + r.messages + .map((msg, index) => + msg.role === 'tool' + ? `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim() + : `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim() + ) + .join('\n\n'), + `History (${r.count})` + ) + }) + + return true + + case 'profile': + rpc('config.get', { key: 'profile' }).then((r: any) => { + if (!r) { + return + } + + const text = r.display || r.home || '(unknown profile)' + const lines = text.split('\n').filter(Boolean) + + lines.length <= 2 ? panel('Profile', [{ text }]) : page(text, 'Profile') + }) + + return true + + case 'voice': + rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { + if (!r) { + return + } + + setVoiceEnabled(!!r?.enabled) + sys(`voice: ${r.enabled ? 'on' : 'off'}`) + }) + + return true + + case 'insights': + rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { + if (!r) { + return + } + + panel('Insights', [ + { + rows: [ + ['Period', `${r.days} days`], + ['Sessions', `${r.sessions}`], + ['Messages', `${r.messages}`] + ] + } + ]) + }) + + return true + } + + return false + } +} + +interface SessionSlashCommand extends ParsedSlashCommand { + sid: null | string +} diff --git a/ui-tui/src/app/slash/shared.ts b/ui-tui/src/app/slash/shared.ts new file mode 100644 index 0000000000..221a7e5aea --- /dev/null +++ b/ui-tui/src/app/slash/shared.ts @@ -0,0 +1,48 @@ +import type { SlashExecResponse } from '../../gatewayTypes.js' +import { rpcErrorMessage } from '../../lib/rpc.js' + +export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { + const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) + + return { + arg: rest.join(' '), + cmd, + name: rawName.toLowerCase() + } +} + +export const createSlashShared = ({ gw, page, sys }: SlashSharedDeps): SlashShared => ({ + showSlashOutput: (title, command, sid) => { + gw.request('slash.exec', { command, session_id: sid }) + .then(r => { + const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' + + const lines = text.split('\n').filter(Boolean) + + if (lines.length > 2 || text.length > 180) { + page(text, title) + } else { + sys(text) + } + }) + .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + } +}) + +export interface ParsedSlashCommand { + arg: string + cmd: string + name: string +} + +export interface SlashShared { + showSlashOutput: (title: string, command: string, sid: null | string) => void +} + +interface SlashSharedDeps { + gw: { + request: (method: string, params?: Record) => Promise + } + page: (text: string, title?: string) => void + sys: (text: string) => void +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 0b1d4e95b4..ef3a7aba0d 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -125,13 +125,7 @@ function TreeNode({ ) } -export function Spinner({ - color, - variant = 'think' -}: { - color: string - variant?: 'think' | 'tool' -}) { +export function Spinner({ color, variant = 'think' }: { color: string; variant?: 'think' | 'tool' }) { const spin = useMemo(() => { const raw = spinners[pick(variant === 'tool' ? TOOL : THINK)] diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 4f91a386b4..de6e71e2ea 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -73,6 +73,7 @@ export function useVirtualHistory( }, [items]) const offsets = useMemo(() => { + void ver const out = new Array(items.length + 1).fill(0) for (let i = 0; i < items.length; i++) { diff --git a/ui-tui/vitest.config.ts b/ui-tui/vitest.config.ts new file mode 100644 index 0000000000..b3efa48af9 --- /dev/null +++ b/ui-tui/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + exclude: ['dist/**', 'node_modules/**'] + } +}) From 8e06db56fd6677d02d70d6e6db476a99aff7e6d5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 01:04:35 -0500 Subject: [PATCH 117/157] chore: uptick --- .../src/__tests__/asCommandDispatch.test.ts | 21 + .../src/__tests__/createSlashHandler.test.ts | 69 + ui-tui/src/app.tsx | 1283 +---------------- ui-tui/src/app/createSlashHandler.ts | 38 +- ui-tui/src/app/interfaces.ts | 1 + .../src/app/slash/createSlashCoreHandler.ts | 21 +- ui-tui/src/app/slash/createSlashOpsHandler.ts | 138 +- .../app/slash/createSlashSessionHandler.ts | 158 +- ui-tui/src/app/slash/isStaleSlash.ts | 10 + ui-tui/src/app/slash/shared.ts | 22 +- ui-tui/src/app/useLongRunToolCharms.ts | 62 + ui-tui/src/app/useMainApp.ts | 1216 ++++++++++++++++ ui-tui/src/gatewayTypes.ts | 5 + ui-tui/src/lib/rpc.ts | 30 + 14 files changed, 1712 insertions(+), 1362 deletions(-) create mode 100644 ui-tui/src/__tests__/asCommandDispatch.test.ts create mode 100644 ui-tui/src/app/slash/isStaleSlash.ts create mode 100644 ui-tui/src/app/useLongRunToolCharms.ts create mode 100644 ui-tui/src/app/useMainApp.ts diff --git a/ui-tui/src/__tests__/asCommandDispatch.test.ts b/ui-tui/src/__tests__/asCommandDispatch.test.ts new file mode 100644 index 0000000000..49ea56936c --- /dev/null +++ b/ui-tui/src/__tests__/asCommandDispatch.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { asCommandDispatch } from '../lib/rpc.js' + +describe('asCommandDispatch', () => { + it('parses exec, alias, and skill', () => { + expect(asCommandDispatch({ type: 'exec', output: 'hi' })).toEqual({ type: 'exec', output: 'hi' }) + expect(asCommandDispatch({ type: 'alias', target: 'help' })).toEqual({ type: 'alias', target: 'help' }) + expect(asCommandDispatch({ type: 'skill', name: 'x', message: 'do' })).toEqual({ + type: 'skill', + name: 'x', + message: 'do' + }) + }) + + it('rejects malformed payloads', () => { + expect(asCommandDispatch(null)).toBeNull() + expect(asCommandDispatch({ type: 'alias' })).toBeNull() + expect(asCommandDispatch({ type: 'skill', name: 1 })).toBeNull() + }) +}) diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index b7f8955398..6a48bc1be8 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -39,6 +39,73 @@ describe('createSlashHandler', () => { expect(ctx.transcript.sys).toHaveBeenNthCalledWith(3, 'MCP tool: /tools enable github:create_issue') }) + it('drops stale slash.exec output after a newer slash', async () => { + let resolveLate: (v: { output?: string }) => void + let slashExecCalls = 0 + + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + slashExecCalls += 1 + + if (slashExecCalls === 1) { + return new Promise<{ output?: string }>(res => { + resolveLate = res + }) + } + + return Promise.resolve({ output: 'fresh' }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/slow')).toBe(true) + expect(h('/fast')).toBe(true) + resolveLate!({ output: 'too late' }) + await vi.waitFor(() => { + expect(ctx.transcript.sys).toHaveBeenCalled() + }) + + expect(ctx.transcript.sys).not.toHaveBeenCalledWith('too late') + }) + + it('dispatches command.dispatch with typed alias', async () => { + const ctx = buildCtx({ + gateway: { + gw: { + getLogTail: vi.fn(() => ''), + request: vi.fn((method: string) => { + if (method === 'slash.exec') { + return Promise.reject(new Error('no')) + } + + if (method === 'command.dispatch') { + return Promise.resolve({ type: 'alias', target: 'help' }) + } + + return Promise.resolve({}) + }) + }, + rpc: vi.fn(() => Promise.resolve({})) + } + }) + + const h = createSlashHandler(ctx) + expect(h('/zzz')).toBe(true) + await vi.waitFor(() => { + expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array)) + }) + }) + it('resolves unique local aliases through the catalog', () => { const ctx = buildCtx({ local: { @@ -58,6 +125,7 @@ describe('createSlashHandler', () => { const buildCtx = (overrides: Partial = {}): Ctx => ({ ...overrides, + slashFlightRef: overrides.slashFlightRef ?? { current: 0 }, composer: { ...buildComposer(), ...overrides.composer }, gateway: { ...buildGateway(), ...overrides.gateway }, local: { ...buildLocal(), ...overrides.local }, @@ -114,6 +182,7 @@ const buildVoice = () => ({ }) interface Ctx { + slashFlightRef: { current: number } composer: ReturnType gateway: ReturnType local: ReturnType diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index ee1a709780..4968d74c29 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,1286 +1,11 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' -import { useStore } from '@nanostores/react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { MAX_HISTORY, MOUSE_TRACKING, PASTE_SNIPPET_RE, STARTUP_RESUME_ID, WHEEL_SCROLL_STEP } from './app/constants.js' -import { createGatewayEventHandler } from './app/createGatewayEventHandler.js' -import { createSlashHandler } from './app/createSlashHandler.js' +import { MOUSE_TRACKING } from './app/constants.js' import { GatewayProvider } from './app/gatewayContext.js' -import { - imageTokenMeta, - introMsg, - looksLikeSlashCommand, - resolveDetailsMode, - shortCwd, - toTranscriptMessages -} from './app/helpers.js' -import { type GatewayRpc, type TranscriptRow } from './app/interfaces.js' -import { $isBlocked, $overlayState, patchOverlayState } from './app/overlayStore.js' -import { $uiState, getUiState, patchUiState } from './app/uiStore.js' -import { useComposerState } from './app/useComposerState.js' -import { useInputHandlers } from './app/useInputHandlers.js' -import { useTurnState } from './app/useTurnState.js' +import { useMainApp } from './app/useMainApp.js' import { AppLayout } from './components/appLayout.js' -import { INTERPOLATION_RE, ZERO } from './constants.js' -import { type GatewayClient } from './gatewayClient.js' -import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from './gatewayTypes.js' -import { useVirtualHistory } from './hooks/useVirtualHistory.js' -import { asRpcResult, rpcErrorMessage } from './lib/rpc.js' -import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from './lib/text.js' -import type { Msg, PanelSection, SessionInfo, SlashCatalog } from './types.js' - -const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i -const LONG_RUN_CHARM_DELAY_MS = 8_000 -const LONG_RUN_CHARM_INTERVAL_MS = 10_000 -const LONG_RUN_CHARM_MAX = 2 - -const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] - -// ── App ────────────────────────────────────────────────────────────── +import type { GatewayClient } from './gatewayClient.js' export function App({ gw }: { gw: GatewayClient }) { - const { exit } = useApp() - const { stdout } = useStdout() - const [cols, setCols] = useState(stdout?.columns ?? 80) - - useEffect(() => { - if (!stdout) { - return - } - - const sync = () => setCols(stdout.columns ?? 80) - stdout.on('resize', sync) - - // Enable bracketed paste so image-only clipboard paste reaches the app - if (stdout.isTTY) { - stdout.write('\x1b[?2004h') - } - - return () => { - stdout.off('resize', sync) - - if (stdout.isTTY) { - stdout.write('\x1b[?2004l') - } - } - }, [stdout]) - - // ── State ──────────────────────────────────────────────────────── - - const [historyItems, setHistoryItems] = useState([]) - const [lastUserMsg, setLastUserMsg] = useState('') - const [stickyPrompt, setStickyPrompt] = useState('') - const [catalog, setCatalog] = useState(null) - const [voiceEnabled, setVoiceEnabled] = useState(false) - const [voiceRecording, setVoiceRecording] = useState(false) - const [voiceProcessing, setVoiceProcessing] = useState(false) - const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) - const [goodVibesTick, setGoodVibesTick] = useState(0) - const [bellOnComplete, setBellOnComplete] = useState(false) - const ui = useStore($uiState) - const overlay = useStore($overlayState) - const isBlocked = useStore($isBlocked) - - // ── Refs ───────────────────────────────────────────────────────── - - const slashRef = useRef<(cmd: string) => boolean>(() => false) - const lastEmptyAt = useRef(0) - const colsRef = useRef(cols) - const scrollRef = useRef(null) - const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) - const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) - const submitRef = useRef<(value: string) => void>(() => {}) - const configMtimeRef = useRef(0) - const historyItemsRef = useRef(historyItems) - const lastUserMsgRef = useRef(lastUserMsg) - const longRunCharmRef = useRef(new Map()) - const msgIdsRef = useRef(new WeakMap()) - const nextMsgIdRef = useRef(0) - colsRef.current = cols - historyItemsRef.current = historyItems - lastUserMsgRef.current = lastUserMsg - - // ── Hooks ──────────────────────────────────────────────────────── - - const hasSelection = useHasSelection() - const selection = useSelection() - const turn = useTurnState() - const turnActions = turn.actions - const turnRefs = turn.refs - const turnState = turn.state - - const composer = useComposerState({ - gw, - onClipboardPaste: quiet => clipboardPasteRef.current(quiet), - submitRef - }) - - const composerActions = composer.actions - const composerRefs = composer.refs - const composerState = composer.state - - const empty = !historyItems.some(msg => msg.kind !== 'intro') - - const messageId = useCallback((msg: Msg) => { - const hit = msgIdsRef.current.get(msg) - - if (hit) { - return hit - } - - const next = `m${++nextMsgIdRef.current}` - msgIdsRef.current.set(msg, next) - - return next - }, []) - - const virtualRows = useMemo( - () => - historyItems.map((msg, index) => ({ - index, - key: messageId(msg), - msg - })), - [historyItems, messageId] - ) - - const virtualHistory = useVirtualHistory(scrollRef, virtualRows) - - const scrollWithSelection = useCallback( - (delta: number) => { - const s = scrollRef.current - - const sel = selection.getState() as { - anchor?: { row: number } - focus?: { row: number } - isDragging?: boolean - } | null - - if (!s || !sel?.anchor || !sel.focus) { - s?.scrollBy(delta) - - return - } - - const top = s.getViewportTop() - const bottom = top + s.getViewportHeight() - 1 - - if (sel.anchor.row < top || sel.anchor.row > bottom) { - s.scrollBy(delta) - - return - } - - if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { - s.scrollBy(delta) - - return - } - - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) - const cur = s.getScrollTop() + s.getPendingDelta() - const actual = Math.max(0, Math.min(max, cur + delta)) - cur - - if (actual === 0) { - return - } - - if (actual > 0) { - selection.captureScrolledRows(top, top + actual - 1, 'above') - sel.isDragging ? selection.shiftAnchor(-actual, top, bottom) : selection.shiftSelection(-actual, top, bottom) - } else { - const amount = -actual - selection.captureScrolledRows(bottom - amount + 1, bottom, 'below') - sel.isDragging ? selection.shiftAnchor(amount, top, bottom) : selection.shiftSelection(amount, top, bottom) - } - - s.scrollBy(delta) - }, - [selection] - ) - - // ── Core actions ───────────────────────────────────────────────── - - const appendMessage = useCallback((msg: Msg) => { - const cap = (items: Msg[]) => - items.length <= MAX_HISTORY - ? items - : items[0]?.kind === 'intro' - ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] - : items.slice(-MAX_HISTORY) - - setHistoryItems(prev => cap([...prev, msg])) - }, []) - - const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - - const page = useCallback((text: string, title?: string) => { - const lines = text.split('\n') - patchOverlayState({ pager: { lines, offset: 0, title } }) - }, []) - - const panel = useCallback( - (title: string, sections: PanelSection[]) => { - appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) - }, - [appendMessage] - ) - - const maybeWarn = useCallback( - (value: any) => { - if (value?.warning) { - sys(`warning: ${value.warning}`) - } - }, - [sys] - ) - - const maybeGoodVibes = useCallback((text: string) => { - if (!GOOD_VIBES_RE.test(text)) { - return - } - - setGoodVibesTick(v => v + 1) - }, []) - - const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { - const display = cfg?.config?.display ?? {} - - setBellOnComplete(!!display?.bell_on_complete) - patchUiState({ - compact: !!display?.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display?.tui_statusbar !== false - }) - }, []) - - const rpc: GatewayRpc = useCallback( - async = Record>( - method: string, - params: Record = {} - ) => { - try { - const result = asRpcResult(await gw.request(method, params)) - - if (result) { - return result - } - - sys(`error: invalid response: ${method}`) - } catch (e) { - sys(`error: ${rpcErrorMessage(e)}`) - } - - return null - }, - [gw, sys] - ) - - const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) - - // ── Resize RPC ─────────────────────────────────────────────────── - - useEffect(() => { - if (!ui.sid || !stdout) { - return - } - - const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) - stdout.on('resize', onResize) - - return () => { - stdout.off('resize', onResize) - } - }, [rpc, stdout, ui.sid]) - - const answerClarify = useCallback( - (answer: string) => { - const clarify = overlay.clarify - - if (!clarify) { - return - } - - const label = toolTrailLabel('clarify') - const nextTrail = turnRefs.turnToolsRef.current.filter(line => !sameToolTrailGroup(label, line)) - - turnRefs.turnToolsRef.current = nextTrail - turnActions.setTurnTrail(nextTrail) - - rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { - if (!r) { - return - } - - if (answer) { - turnRefs.persistedToolLabelsRef.current.add(label) - appendMessage({ - role: 'system', - text: '', - kind: 'trail', - tools: [buildToolTrailLine('clarify', clarify.question)] - }) - appendMessage({ role: 'user', text: answer }) - patchUiState({ status: 'running…' }) - } else { - sys('prompt cancelled') - } - - patchOverlayState({ clarify: null }) - }) - }, - [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs] - ) - - useEffect(() => { - if (!ui.sid) { - return - } - - rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) - rpc('config.get', { key: 'mtime' }).then(r => { - configMtimeRef.current = Number(r?.mtime ?? 0) - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - }, [applyDisplayConfig, rpc, ui.sid]) - - useEffect(() => { - if (!ui.sid) { - return - } - - const id = setInterval(() => { - rpc('config.get', { key: 'mtime' }).then(r => { - const next = Number(r?.mtime ?? 0) - - if (configMtimeRef.current && next && next !== configMtimeRef.current) { - configMtimeRef.current = next - rpc('reload.mcp', { session_id: ui.sid }).then(r => { - if (!r) { - return - } - - turnActions.pushActivity('MCP reloaded after config change') - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - } else if (!configMtimeRef.current && next) { - configMtimeRef.current = next - } - }) - }, 5000) - - return () => clearInterval(id) - }, [applyDisplayConfig, turnActions, rpc, ui.sid]) - - const idle = turnActions.idle - const clearReasoning = turnActions.clearReasoning - - const die = useCallback(() => { - gw.kill() - exit() - }, [exit, gw]) - - const resetSession = useCallback(() => { - idle() - clearReasoning() - setVoiceRecording(false) - setVoiceProcessing(false) - patchUiState({ - bgTasks: new Set(), - info: null, - sid: null, - usage: ZERO - }) - setHistoryItems([]) - setLastUserMsg('') - setStickyPrompt('') - composerActions.setPasteSnips([]) - turnActions.setActivity([]) - turnRefs.turnToolsRef.current = [] - turnRefs.lastStatusNoteRef.current = '' - turnRefs.protocolWarnedRef.current = false - turnRefs.persistedToolLabelsRef.current.clear() - }, [clearReasoning, composerActions, idle, turnActions, turnRefs]) - - const resetVisibleHistory = useCallback( - (info: SessionInfo | null = null) => { - idle() - clearReasoning() - setHistoryItems(info ? [introMsg(info)] : []) - patchUiState({ - info, - usage: info?.usage ? { ...ZERO, ...info.usage } : ZERO - }) - setStickyPrompt('') - composerActions.setPasteSnips([]) - turnActions.setActivity([]) - setLastUserMsg('') - turnRefs.turnToolsRef.current = [] - turnRefs.persistedToolLabelsRef.current.clear() - }, - [clearReasoning, composerActions, idle, turnActions, turnRefs] - ) - - const trimLastExchange = useCallback((items: Msg[]) => { - const q = [...items] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - if (q.at(-1)?.role === 'user') { - q.pop() - } - - return q - }, []) - - const guardBusySessionSwitch = useCallback( - (what = 'switch sessions') => { - if (!getUiState().busy) { - return false - } - - sys(`interrupt the current turn before trying to ${what}`) - - return true - }, - [sys] - ) - - const closeSession = useCallback( - (targetSid?: string | null) => { - if (!targetSid) { - return Promise.resolve(null) - } - - return rpc('session.close', { session_id: targetSid }) - }, - [rpc] - ) - - // ── Session management ─────────────────────────────────────────── - - const newSession = useCallback( - async (msg?: string) => { - await closeSession(getUiState().sid) - - return rpc('session.create', { cols: colsRef.current }).then(r => { - if (!r) { - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO - }) - - if (r.info) { - setHistoryItems([introMsg(r.info)]) - } - - if (r.info?.credential_warning) { - sys(`warning: ${r.info.credential_warning}`) - } - - if (msg) { - sys(msg) - } - }) - }, - [closeSession, resetSession, rpc, sys] - ) - - const resumeById = useCallback( - (id: string) => { - patchOverlayState({ picker: false }) - patchUiState({ status: 'resuming…' }) - closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => - gw - .request('session.resume', { cols: colsRef.current, session_id: id }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: session.resume') - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - const resumed = toTranscriptMessages(r.messages) - - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO - }) - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ status: 'ready' }) - }) - ) - }, - [closeSession, gw, resetSession, sys] - ) - - // ── Paste pipeline ─────────────────────────────────────────────── - - const paste = useCallback( - (quiet = false) => - rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => { - if (!r) { - return - } - - if (r.attached) { - const meta = imageTokenMeta(r) - sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) - - return - } - - quiet || sys(r.message || 'No image found in clipboard') - }), - [rpc, sys] - ) - - clipboardPasteRef.current = paste - const handleTextPaste = composerActions.handleTextPaste - - // ── Send ───────────────────────────────────────────────────────── - - const send = useCallback( - (text: string) => { - const expandPasteSnips = (value: string) => { - const byLabel = new Map() - - for (const item of composerState.pasteSnips) { - const list = byLabel.get(item.label) - list ? list.push(item.text) : byLabel.set(item.label, [item.text]) - } - - return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) - } - - const startSubmit = (displayText: string, submitText: string) => { - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - if (turnRefs.statusTimerRef.current) { - clearTimeout(turnRefs.statusTimerRef.current) - turnRefs.statusTimerRef.current = null - } - - maybeGoodVibes(submitText) - setLastUserMsg(text) - appendMessage({ role: 'user', text: displayText }) - patchUiState({ busy: true, status: 'running…' }) - turnRefs.bufRef.current = '' - turnRefs.interruptedRef.current = false - - gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ busy: false, status: 'ready' }) - }) - } - - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - gw.request('input.detect_drop', { session_id: sid, text }) - .then((r: any) => { - if (r?.matched) { - if (r.is_image) { - const meta = imageTokenMeta(r) - turnActions.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - } else { - turnActions.pushActivity(`detected file: ${r.name}`) - } - - startSubmit(r.text || text, expandPasteSnips(r.text || text)) - - return - } - - startSubmit(text, expandPasteSnips(text)) - }) - .catch(() => startSubmit(text, expandPasteSnips(text))) - }, - [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs] - ) - - const shellExec = useCallback( - (cmd: string) => { - appendMessage({ role: 'user', text: `!${cmd}` }) - patchUiState({ busy: true, status: 'running…' }) - - gw.request('shell.exec', { command: cmd }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: shell.exec') - - return - } - - const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() - - if (out) { - sys(out) - } - - if (r.code !== 0 || !out) { - sys(`exit ${r.code}`) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - .finally(() => { - patchUiState({ busy: false, status: 'ready' }) - }) - }, - [appendMessage, gw, sys] - ) - - const openEditor = composerActions.openEditor - - const interpolate = useCallback( - (text: string, then: (result: string) => void) => { - patchUiState({ status: 'interpolating…' }) - const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] - - Promise.all( - matches.map(m => - gw - .request('shell.exec', { command: m[1]! }) - .then((raw: any) => { - const r = asRpcResult(raw) - - return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() - }) - .catch(() => '(error)') - ) - ).then(results => { - let out = text - - for (let i = matches.length - 1; i >= 0; i--) { - out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) - } - - then(out) - }) - }, - [gw] - ) - - const sendQueued = useCallback( - (text: string) => { - if (text.startsWith('!')) { - shellExec(text.slice(1).trim()) - - return - } - - if (hasInterpolation(text)) { - patchUiState({ busy: true }) - interpolate(text, send) - - return - } - - send(text) - }, - [interpolate, send, shellExec] - ) - - // ── Dispatch ───────────────────────────────────────────────────── - - const dispatchSubmission = useCallback( - (full: string) => { - const live = getUiState() - - if (!full.trim()) { - return - } - - if (!live.sid) { - sys('session not ready yet') - - return - } - - if (looksLikeSlashCommand(full)) { - appendMessage({ role: 'system', text: full, kind: 'slash' }) - composerActions.pushHistory(full) - slashRef.current(full) - composerActions.clearIn() - - return - } - - if (full.startsWith('!')) { - composerActions.clearIn() - shellExec(full.slice(1).trim()) - - return - } - - const editIdx = composerRefs.queueEditRef.current - composerActions.clearIn() - - if (editIdx !== null) { - composerActions.replaceQueue(editIdx, full) - const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] - composerActions.syncQueue() - composerActions.setQueueEdit(null) - - if (picked && getUiState().busy && live.sid) { - composerRefs.queueRef.current.unshift(picked) - composerActions.syncQueue() - - return - } - - if (picked && live.sid) { - sendQueued(picked) - } - - return - } - - composerActions.pushHistory(full) - - if (getUiState().busy) { - composerActions.enqueue(full) - - return - } - - if (hasInterpolation(full)) { - patchUiState({ busy: true }) - interpolate(full, send) - - return - } - - send(full) - }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] - ) - - // ── Input handling ─────────────────────────────────────────────── - const { pagerPageSize } = useInputHandlers({ - actions: { - answerClarify, - appendMessage, - die, - dispatchSubmission, - guardBusySessionSwitch, - newSession, - sys - }, - composer: { - actions: composerActions, - refs: composerRefs, - state: composerState - }, - gateway, - terminal: { - hasSelection, - scrollRef, - scrollWithSelection, - selection, - stdout - }, - turn: { - actions: turnActions, - refs: turnRefs - }, - voice: { - recording: voiceRecording, - setProcessing: setVoiceProcessing, - setRecording: setVoiceRecording - }, - wheelStep: WHEEL_SCROLL_STEP - }) - - // ── Gateway events ─────────────────────────────────────────────── - - const onEvent = useMemo( - () => - createGatewayEventHandler({ - composer: { - dequeue: composerActions.dequeue, - queueEditRef: composerRefs.queueEditRef, - sendQueued - }, - gateway, - session: { - STARTUP_RESUME_ID, - colsRef, - newSession, - resetSession, - setCatalog - }, - system: { - bellOnComplete, - stdout, - sys - }, - transcript: { - appendMessage, - setHistoryItems - }, - turn: { - actions: { - clearReasoning, - endReasoningPhase: turnActions.endReasoningPhase, - idle, - pruneTransient: turnActions.pruneTransient, - pulseReasoningStreaming: turnActions.pulseReasoningStreaming, - pushActivity: turnActions.pushActivity, - pushTrail: turnActions.pushTrail, - scheduleReasoning: turnActions.scheduleReasoning, - scheduleStreaming: turnActions.scheduleStreaming, - setActivity: turnActions.setActivity, - setReasoningTokens: turnActions.setReasoningTokens, - setStreaming: turnActions.setStreaming, - setSubagents: turnActions.setSubagents, - setToolTokens: turnActions.setToolTokens, - setTools: turnActions.setTools, - setTurnTrail: turnActions.setTurnTrail - }, - refs: { - activeToolsRef: turnRefs.activeToolsRef, - bufRef: turnRefs.bufRef, - interruptedRef: turnRefs.interruptedRef, - lastStatusNoteRef: turnRefs.lastStatusNoteRef, - persistedToolLabelsRef: turnRefs.persistedToolLabelsRef, - protocolWarnedRef: turnRefs.protocolWarnedRef, - reasoningRef: turnRefs.reasoningRef, - statusTimerRef: turnRefs.statusTimerRef, - toolTokenAccRef: turnRefs.toolTokenAccRef, - toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, - turnToolsRef: turnRefs.turnToolsRef - } - } - }), - [ - appendMessage, - bellOnComplete, - clearReasoning, - composerActions, - composerRefs, - gateway, - idle, - newSession, - resetSession, - sendQueued, - sys, - turnActions, - turnRefs, - stdout - ] - ) - - onEventRef.current = onEvent - - useEffect(() => { - const handler = (ev: GatewayEvent) => onEventRef.current(ev) - - const exitHandler = () => { - patchUiState({ busy: false, sid: null, status: 'gateway exited' }) - turnActions.pushActivity('gateway exited · /logs to inspect', 'error') - sys('error: gateway exited') - } - - gw.on('event', handler) - gw.on('exit', exitHandler) - gw.drain() - - return () => { - gw.off('event', handler) - gw.off('exit', exitHandler) - gw.kill() - } - }, [gw, turnActions, sys]) - - useEffect(() => { - if (!ui.busy || !turnState.tools.length) { - longRunCharmRef.current.clear() - - return - } - - const tick = () => { - const now = Date.now() - const liveIds = new Set(turnState.tools.map(tool => tool.id)) - - for (const key of [...longRunCharmRef.current.keys()]) { - if (!liveIds.has(key)) { - longRunCharmRef.current.delete(key) - } - } - - for (const tool of turnState.tools) { - if (!tool.startedAt || now - tool.startedAt < LONG_RUN_CHARM_DELAY_MS) { - continue - } - - const slot = longRunCharmRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } - - if (slot.count >= LONG_RUN_CHARM_MAX || now - slot.lastAt < LONG_RUN_CHARM_INTERVAL_MS) { - continue - } - - slot.count += 1 - slot.lastAt = now - longRunCharmRef.current.set(tool.id, slot) - - const charm = LONG_RUN_CHARMS[Math.floor(Math.random() * LONG_RUN_CHARMS.length)]! - const sec = Math.round((now - tool.startedAt) / 1000) - turnActions.pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) - } - } - - tick() - const id = setInterval(tick, 1000) - - return () => clearInterval(id) - }, [turnActions, turnState.tools, ui.busy]) - - // ── Slash commands ─────────────────────────────────────────────── - - const slash = useMemo( - () => - createSlashHandler({ - composer: { - enqueue: composerActions.enqueue, - hasSelection, - paste, - queueRef: composerRefs.queueRef, - selection, - setInput: composerActions.setInput - }, - gateway, - local: { - catalog, - getHistoryItems: () => historyItemsRef.current, - getLastUserMsg: () => lastUserMsgRef.current, - maybeWarn - }, - session: { - closeSession, - die, - guardBusySessionSwitch, - newSession, - resetVisibleHistory, - resumeById, - setSessionStartedAt - }, - transcript: { - page, - panel, - send, - setHistoryItems, - sys, - trimLastExchange - }, - voice: { - setVoiceEnabled - } - }), - [ - catalog, - closeSession, - composerActions, - composerRefs, - die, - gateway, - guardBusySessionSwitch, - hasSelection, - maybeWarn, - newSession, - page, - panel, - paste, - resetVisibleHistory, - resumeById, - selection, - send, - setSessionStartedAt, - setHistoryItems, - setVoiceEnabled, - sys, - trimLastExchange - ] - ) - - slashRef.current = slash - - // ── Submit ─────────────────────────────────────────────────────── - - const submit = useCallback( - (value: string) => { - if (value.startsWith('/') && composerState.completions.length) { - const row = composerState.completions[composerState.compIdx] - - if (row?.text) { - const text = - value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0 - ? row.text.slice(1) - : row.text - - const next = value.slice(0, composerState.compReplace) + text - - if (next !== value) { - composerActions.setInput(next) - - return - } - } - } - - if (!value.trim() && !composerState.inputBuf.length) { - const live = getUiState() - const now = Date.now() - const dbl = now - lastEmptyAt.current < 450 - lastEmptyAt.current = now - - if (dbl && live.busy && live.sid) { - turnActions.interruptTurn({ - appendMessage, - gw, - sid: live.sid, - sys - }) - - return - } - - if (dbl && composerRefs.queueRef.current.length) { - const next = composerActions.dequeue() - - if (next && live.sid) { - composerActions.setQueueEdit(null) - dispatchSubmission(next) - } - } - - return - } - - lastEmptyAt.current = 0 - - if (value.endsWith('\\')) { - composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) - composerActions.setInput('') - - return - } - - dispatchSubmission([...composerState.inputBuf, value].join('\n')) - }, - [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions] - ) - - submitRef.current = submit - - // ── Derived ────────────────────────────────────────────────────── - - const statusColor = - ui.status === 'ready' - ? ui.theme.color.ok - : ui.status.startsWith('error') - ? ui.theme.color.error - : ui.status === 'interrupted' - ? ui.theme.color.warn - : ui.theme.color.dim - - const sessionStarted = ui.sid ? sessionStartedAt : null - const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` - const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) - const showStreamingArea = Boolean(turnState.streaming) - const showStickyPrompt = !!stickyPrompt - - const hasReasoning = Boolean(turnState.reasoning.trim()) - - const showProgressArea = - ui.detailsMode === 'hidden' - ? turnState.activity.some(item => item.tone !== 'info') - : Boolean( - ui.busy || - turnState.subagents.length || - turnState.tools.length || - turnState.turnTrail.length || - hasReasoning || - turnState.activity.length - ) - - const answerApproval = useCallback( - (choice: string) => { - rpc('approval.respond', { choice, session_id: ui.sid }).then(r => { - if (!r) { - return - } - - patchOverlayState({ approval: null }) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) - patchUiState({ status: 'running…' }) - }) - }, - [rpc, sys, ui.sid] - ) - - const answerSudo = useCallback( - (pw: string) => { - if (!overlay.sudo) { - return - } - - rpc('sudo.respond', { request_id: overlay.sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - - patchOverlayState({ sudo: null }) - patchUiState({ status: 'running…' }) - }) - }, - [overlay.sudo, rpc] - ) - - const answerSecret = useCallback( - (value: string) => { - if (!overlay.secret) { - return - } - - rpc('secret.respond', { request_id: overlay.secret.requestId, value }).then(r => { - if (!r) { - return - } - - patchOverlayState({ secret: null }) - patchUiState({ status: 'running…' }) - }) - }, - [overlay.secret, rpc] - ) - - const onModelSelect = useCallback((value: string) => { - patchOverlayState({ modelPicker: false }) - slashRef.current(`/model ${value}`) - }, []) - - const appActions = useMemo( - () => ({ - answerApproval, - answerClarify, - answerSecret, - answerSudo, - onModelSelect, - resumeById, - setStickyPrompt - }), - [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt] - ) - - const appComposer = useMemo( - () => ({ - cols, - compIdx: composerState.compIdx, - completions: composerState.completions, - empty, - handleTextPaste, - input: composerState.input, - inputBuf: composerState.inputBuf, - pagerPageSize, - queueEditIdx: composerState.queueEditIdx, - queuedDisplay: composerState.queuedDisplay, - submit, - updateInput: composerActions.setInput - }), - [ - cols, - composerActions.setInput, - composerState.compIdx, - composerState.completions, - composerState.input, - composerState.inputBuf, - composerState.queueEditIdx, - composerState.queuedDisplay, - empty, - handleTextPaste, - pagerPageSize, - submit - ] - ) - - const appProgress = useMemo( - () => ({ - activity: turnState.activity, - reasoning: turnState.reasoning, - reasoningActive: turnState.reasoningActive, - reasoningStreaming: turnState.reasoningStreaming, - reasoningTokens: turnState.reasoningTokens, - showProgressArea, - showStreamingArea, - streaming: turnState.streaming, - subagents: turnState.subagents, - toolTokens: turnState.toolTokens, - tools: turnState.tools, - turnTrail: turnState.turnTrail - }), - [showProgressArea, showStreamingArea, turnState] - ) - - const appStatus = useMemo( - () => ({ - cwdLabel, - goodVibesTick, - sessionStartedAt: sessionStarted, - showStickyPrompt, - statusColor, - stickyPrompt, - voiceLabel - }), - [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] - ) - - const appTranscript = useMemo( - () => ({ - historyItems, - scrollRef, - virtualHistory, - virtualRows - }), - [historyItems, scrollRef, virtualHistory, virtualRows] - ) - - // ── Render ─────────────────────────────────────────────────────── + const { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } = useMainApp(gw) return ( diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index b208043805..1a23943c09 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,9 +1,11 @@ -import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import type { SlashExecResponse } from '../gatewayTypes.js' +import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' import type { SlashHandlerContext } from './interfaces.js' import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js' import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js' import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js' +import { isStaleSlash } from './slash/isStaleSlash.js' import { createSlashShared, parseSlashCommand } from './slash/shared.js' import { getUiState } from './uiStore.js' @@ -11,14 +13,16 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b const { gw } = ctx.gateway const { catalog } = ctx.local const { send, sys } = ctx.transcript - const shared = createSlashShared({ ...ctx.transcript, gw }) + const shared = createSlashShared({ ...ctx.transcript, gw, slashFlightRef: ctx.slashFlightRef }) const handleCore = createSlashCoreHandler(ctx) const handleSession = createSlashSessionHandler(ctx, shared) const handleOps = createSlashOpsHandler(ctx) const handler = (cmd: string): boolean => { + const flight = ++ctx.slashFlightRef.current const ui = getUiState() - const parsed = { ...parseSlashCommand(cmd), sid: ui.sid, ui } + const sidAtSend = ui.sid + const parsed = { ...parseSlashCommand(cmd), flight, sid: sidAtSend, ui } const argTail = parsed.arg ? ` ${parsed.arg}` : '' if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) { @@ -47,8 +51,12 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } - gw.request('slash.exec', { command: cmd.slice(1), session_id: ui.sid }) - .then((r: any) => { + gw.request('slash.exec', { command: cmd.slice(1), session_id: sidAtSend }) + .then(r => { + if (isStaleSlash(ctx, flight, sidAtSend)) { + return + } + sys( r?.warning ? `warning: ${r.warning}\n${r?.output || `/${parsed.name}: no output`}` @@ -56,11 +64,15 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b ) }) .catch(() => { - gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: ui.sid }) - .then((raw: any) => { - const d = asRpcResult(raw) + gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: sidAtSend }) + .then((raw: unknown) => { + if (isStaleSlash(ctx, flight, sidAtSend)) { + return + } - if (!d?.type) { + const d = asCommandDispatch(raw) + + if (!d) { sys('error: invalid response: command.dispatch') return @@ -80,7 +92,13 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (isStaleSlash(ctx, flight, sidAtSend)) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) }) return true diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 719116cb8d..aa7e28d4dd 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -320,6 +320,7 @@ export interface GatewayEventHandlerContext { } export interface SlashHandlerContext { + slashFlightRef: MutableRefObject composer: { enqueue: (text: string) => void hasSelection: boolean diff --git a/ui-tui/src/app/slash/createSlashCoreHandler.ts b/ui-tui/src/app/slash/createSlashCoreHandler.ts index cb980248a4..7cb893c4c9 100644 --- a/ui-tui/src/app/slash/createSlashCoreHandler.ts +++ b/ui-tui/src/app/slash/createSlashCoreHandler.ts @@ -6,6 +6,8 @@ import type { SlashHandlerContext } from '../interfaces.js' import { patchOverlayState } from '../overlayStore.js' import { patchUiState } from '../uiStore.js' +import { isStaleSlash } from './isStaleSlash.js' + const FORTUNES = [ 'you are one clean refactor away from clarity', 'a tiny rename today prevents a huge bug tomorrow', @@ -53,7 +55,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { const { guardBusySessionSwitch, newSession, resumeById } = ctx.session const { panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript - return ({ arg, name, sid, ui }: SlashCommand) => { + return ({ arg, flight, name, sid, ui }: SlashCommand) => { switch (name) { case 'help': { const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({ @@ -132,12 +134,22 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { ctx.gateway .rpc('config.get', { key: 'details_mode' }) .then((r: any) => { + if (isStaleSlash(ctx, flight, sid)) { + return + } + const mode = parseDetailsMode(r?.value) ?? ui.detailsMode patchUiState({ detailsMode: mode }) sys(`details: ${mode}`) }) - .catch(() => sys(`details: ${ui.detailsMode}`)) + .catch(() => { + if (isStaleSlash(ctx, flight, sid)) { + return + } + + sys(`details: ${ui.detailsMode}`) + }) return true } @@ -265,7 +277,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { } ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { + if (isStaleSlash(ctx, flight, sid) || !r) { return } @@ -294,7 +306,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { } ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (!r) { + if (isStaleSlash(ctx, flight, sid) || !r) { return } @@ -318,6 +330,7 @@ export function createSlashCoreHandler(ctx: SlashHandlerContext) { interface SlashCommand { arg: string + flight: number name: string sid: null | string ui: { diff --git a/ui-tui/src/app/slash/createSlashOpsHandler.ts b/ui-tui/src/app/slash/createSlashOpsHandler.ts index 4627244e3b..9b8a277be5 100644 --- a/ui-tui/src/app/slash/createSlashOpsHandler.ts +++ b/ui-tui/src/app/slash/createSlashOpsHandler.ts @@ -3,6 +3,7 @@ import { rpcErrorMessage } from '../../lib/rpc.js' import type { PanelSection } from '../../types.js' import type { SlashHandlerContext } from '../interfaces.js' +import { isStaleSlash } from './isStaleSlash.js' import type { ParsedSlashCommand } from './shared.js' export function createSlashOpsHandler(ctx: SlashHandlerContext) { @@ -10,14 +11,16 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { const { resetVisibleHistory, setSessionStartedAt } = ctx.session const { panel, sys } = ctx.transcript - return ({ arg, cmd, name, sid }: OpsSlashCommand) => { + return ({ arg, cmd, flight, name, sid }: OpsSlashCommand) => { + const stale = () => isStaleSlash(ctx, flight, sid) + switch (name) { case 'rollback': { const [sub, ...rest] = (arg || 'list').split(/\s+/) if (!sub || sub === 'list') { rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -46,7 +49,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { session_id: sid, hash, ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => r && sys(r.rendered || r.diff || r.message || 'done')) + }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(r.rendered || r.diff || r.message || 'done') + }) return true } @@ -54,16 +63,20 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'browser': { const [action, ...rest] = (arg || 'status').split(/\s+/) - rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then( - (r: any) => r && sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - ) + rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') + }) return true } case 'plugins': rpc('plugins.list', {}).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -86,7 +99,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (!sub || sub === 'list') { rpc('skills.manage', { action: 'list' }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -111,7 +124,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 rpc('skills.manage', { action: 'browse', page: pageNumber }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -149,14 +162,24 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { ctx.gateway.gw .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + sys( r?.warning ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` : r?.output || '/skills: no output' ) - ) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -166,7 +189,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'tasks': rpc('agents.list', {}) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -188,7 +211,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { !sections.length && sections.push({ text: 'No active processes' }) panel('Agents', sections) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true @@ -196,7 +225,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (!arg || arg === 'list') { rpc('cron.manage', { action: 'list' }) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -217,14 +246,30 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { } ]) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) } else { ctx.gateway.gw .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') - ) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) } return true @@ -232,7 +277,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'config': rpc('config.show', {}) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -244,7 +289,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { })) ) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true case 'tools': { @@ -253,6 +304,10 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (!subcommand) { rpc('tools.show', { session_id: sid }) .then(r => { + if (stale()) { + return + } + if (!r?.sections?.length) { sys('no tools') @@ -267,7 +322,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { })) ) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -275,6 +336,10 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { if (subcommand === 'list') { rpc('tools.list', { session_id: sid }) .then(r => { + if (stale()) { + return + } + if (!r?.toolsets?.length) { sys('no tools') @@ -289,7 +354,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { })) ) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -309,7 +380,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { session_id: sid }) .then(r => { - if (!r) { + if (stale() || !r) { return } @@ -323,7 +394,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { r.missing_servers?.length && sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) r.reset && sys('session reset. new tool configuration is active.') }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -336,7 +413,7 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { case 'toolsets': rpc('toolsets.list', { session_id: sid }) .then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -358,7 +435,13 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { } ]) }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true } @@ -368,5 +451,6 @@ export function createSlashOpsHandler(ctx: SlashHandlerContext) { } interface OpsSlashCommand extends ParsedSlashCommand { + flight: number sid: null | string } diff --git a/ui-tui/src/app/slash/createSlashSessionHandler.ts b/ui-tui/src/app/slash/createSlashSessionHandler.ts index 5fa817f5a1..d4c1e404fd 100644 --- a/ui-tui/src/app/slash/createSlashSessionHandler.ts +++ b/ui-tui/src/app/slash/createSlashSessionHandler.ts @@ -7,6 +7,7 @@ import type { SlashHandlerContext } from '../interfaces.js' import { patchOverlayState } from '../overlayStore.js' import { patchUiState } from '../uiStore.js' +import { isStaleSlash } from './isStaleSlash.js' import type { ParsedSlashCommand, SlashShared } from './shared.js' const SLASH_OUTPUT_PAGE: Record = { @@ -24,11 +25,12 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas const { page, panel, setHistoryItems, sys } = ctx.transcript const { setVoiceEnabled } = ctx.voice - return ({ arg, cmd, name, sid }: SessionSlashCommand) => { + return ({ arg, cmd, flight, name, sid }: SessionSlashCommand) => { + const stale = () => isStaleSlash(ctx, flight, sid) const pageTitle = SLASH_OUTPUT_PAGE[name] if (pageTitle) { - shared.showSlashOutput(pageTitle, cmd.slice(1), sid) + shared.showSlashOutput({ command: cmd.slice(1), flight, sid, title: pageTitle }) return true } @@ -44,6 +46,10 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } rpc('prompt.background', { session_id: sid, text: arg }).then(r => { + if (stale()) { + return + } + const taskId = r?.task_id if (!taskId) { @@ -64,7 +70,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -86,7 +92,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -108,7 +114,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'image': rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -122,56 +128,94 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'provider': gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + page( r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', 'Provider' ) - ) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true case 'skin': if (arg) { - rpc('config.set', { key: 'skin', value: arg }).then((r: any) => r?.value && sys(`skin → ${r.value}`)) + rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { + if (stale() || !r?.value) { + return + } + + sys(`skin → ${r.value}`) + }) } else { - rpc('config.get', { key: 'skin' }).then((r: any) => r && sys(`skin: ${r.value || 'default'}`)) + rpc('config.get', { key: 'skin' }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`skin: ${r.value || 'default'}`) + }) } return true case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then( - (r: any) => r && sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - ) + rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) + }) return true case 'reasoning': if (!arg) { - rpc('config.get', { key: 'reasoning' }).then( - (r: any) => r?.value && sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) - ) + rpc('config.get', { key: 'reasoning' }).then((r: any) => { + if (stale() || !r?.value) { + return + } + + sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + }) } else { - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then( - (r: any) => r?.value && sys(`reasoning: ${r.value}`) - ) + rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { + if (stale() || !r?.value) { + return + } + + sys(`reasoning: ${r.value}`) + }) } return true case 'verbose': - rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then( - (r: any) => r?.value && sys(`verbose: ${r.value}`) - ) + rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { + if (stale() || !r?.value) { + return + } + + sys(`verbose: ${r.value}`) + }) return true case 'personality': if (arg) { rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -184,20 +228,30 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => + .then((r: any) => { + if (stale()) { + return + } + panel('Personality', [ { text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)' } ]) - ) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + }) + .catch((e: unknown) => { + if (stale()) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) return true case 'compress': rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -220,7 +274,13 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas return true case 'stop': - rpc('process.stop', {}).then((r: any) => r && sys(`killed ${r.killed ?? 0} registered process(es)`)) + rpc('process.stop', {}).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`killed ${r.killed ?? 0} registered process(es)`) + }) return true @@ -229,7 +289,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas const prevSid = sid rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (!r?.session_id) { + if (stale() || !r?.session_id) { return } @@ -246,19 +306,33 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'reload-mcp': case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then((r: any) => r && sys('MCP reloaded')) + rpc('reload.mcp', { session_id: sid }).then((r: any) => { + if (stale() || !r) { + return + } + + sys('MCP reloaded') + }) return true case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then( - (r: any) => r && sys(`title: ${r.title || '(none)'}`) - ) + rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { + if (stale() || !r) { + return + } + + sys(`title: ${r.title || '(none)'}`) + }) return true case 'usage': rpc('session.usage', { session_id: sid }).then((r: any) => { + if (stale()) { + return + } + if (r) { patchUiState({ usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 } @@ -272,6 +346,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } const f = (v: number) => (v ?? 0).toLocaleString() + const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null @@ -297,13 +372,19 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas return true case 'save': - rpc('session.save', { session_id: sid }).then((r: any) => r?.file && sys(`saved: ${r.file}`)) + rpc('session.save', { session_id: sid }).then((r: any) => { + if (stale() || !r?.file) { + return + } + + sys(`saved: ${r.file}`) + }) return true case 'history': rpc('session.history', { session_id: sid }).then(r => { - if (typeof r?.count !== 'number') { + if (stale() || typeof r?.count !== 'number') { return } @@ -329,7 +410,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'profile': rpc('config.get', { key: 'profile' }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -343,7 +424,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'voice': rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -355,7 +436,7 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas case 'insights': rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { - if (!r) { + if (stale() || !r) { return } @@ -378,5 +459,6 @@ export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: Slas } interface SessionSlashCommand extends ParsedSlashCommand { + flight: number sid: null | string } diff --git a/ui-tui/src/app/slash/isStaleSlash.ts b/ui-tui/src/app/slash/isStaleSlash.ts new file mode 100644 index 0000000000..0d8386fbd5 --- /dev/null +++ b/ui-tui/src/app/slash/isStaleSlash.ts @@ -0,0 +1,10 @@ +import type { SlashHandlerContext } from '../interfaces.js' +import { getUiState } from '../uiStore.js' + +export function isStaleSlash( + ctx: Pick, + flight: number, + sid: null | string +): boolean { + return flight !== ctx.slashFlightRef.current || getUiState().sid !== sid +} diff --git a/ui-tui/src/app/slash/shared.ts b/ui-tui/src/app/slash/shared.ts index 221a7e5aea..e862045cf6 100644 --- a/ui-tui/src/app/slash/shared.ts +++ b/ui-tui/src/app/slash/shared.ts @@ -1,5 +1,8 @@ +import type { MutableRefObject } from 'react' + import type { SlashExecResponse } from '../../gatewayTypes.js' import { rpcErrorMessage } from '../../lib/rpc.js' +import { getUiState } from '../uiStore.js' export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) @@ -11,10 +14,14 @@ export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { } } -export const createSlashShared = ({ gw, page, sys }: SlashSharedDeps): SlashShared => ({ - showSlashOutput: (title, command, sid) => { +export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({ + showSlashOutput: ({ command, flight, sid, title }) => { gw.request('slash.exec', { command, session_id: sid }) .then(r => { + if (flight !== slashFlightRef.current || getUiState().sid !== sid) { + return + } + const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' const lines = text.split('\n').filter(Boolean) @@ -25,7 +32,13 @@ export const createSlashShared = ({ gw, page, sys }: SlashSharedDeps): SlashShar sys(text) } }) - .catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`)) + .catch((e: unknown) => { + if (flight !== slashFlightRef.current || getUiState().sid !== sid) { + return + } + + sys(`error: ${rpcErrorMessage(e)}`) + }) } }) @@ -36,7 +49,7 @@ export interface ParsedSlashCommand { } export interface SlashShared { - showSlashOutput: (title: string, command: string, sid: null | string) => void + showSlashOutput: (opts: { command: string; flight: number; sid: null | string; title: string }) => void } interface SlashSharedDeps { @@ -44,5 +57,6 @@ interface SlashSharedDeps { request: (method: string, params?: Record) => Promise } page: (text: string, title?: string) => void + slashFlightRef: MutableRefObject sys: (text: string) => void } diff --git a/ui-tui/src/app/useLongRunToolCharms.ts b/ui-tui/src/app/useLongRunToolCharms.ts new file mode 100644 index 0000000000..63583fb70d --- /dev/null +++ b/ui-tui/src/app/useLongRunToolCharms.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react' + +import { toolTrailLabel } from '../lib/text.js' +import type { ActiveTool, ActivityItem } from '../types.js' + +const DELAY_MS = 8_000 +const INTERVAL_MS = 10_000 +const MAX = 2 +const CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] + +export function useLongRunToolCharms( + busy: boolean, + tools: ActiveTool[], + pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void +) { + const slotRef = useRef(new Map()) + + useEffect(() => { + if (!busy || !tools.length) { + slotRef.current.clear() + + return + } + + const tick = () => { + const now = Date.now() + const liveIds = new Set(tools.map(t => t.id)) + + for (const key of [...slotRef.current.keys()]) { + if (!liveIds.has(key)) { + slotRef.current.delete(key) + } + } + + for (const tool of tools) { + if (!tool.startedAt || now - tool.startedAt < DELAY_MS) { + continue + } + + const slot = slotRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } + + if (slot.count >= MAX || now - slot.lastAt < INTERVAL_MS) { + continue + } + + slot.count += 1 + slot.lastAt = now + slotRef.current.set(tool.id, slot) + + const charm = CHARMS[Math.floor(Math.random() * CHARMS.length)]! + const sec = Math.round((now - tool.startedAt) / 1000) + + pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) + } + } + + tick() + const id = setInterval(tick, 1000) + + return () => clearInterval(id) + }, [busy, pushActivity, tools]) +} diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts new file mode 100644 index 0000000000..1abc4bdde1 --- /dev/null +++ b/ui-tui/src/app/useMainApp.ts @@ -0,0 +1,1216 @@ +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { INTERPOLATION_RE, ZERO } from '../constants.js' +import { type GatewayClient } from '../gatewayClient.js' +import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from '../gatewayTypes.js' +import { useVirtualHistory } from '../hooks/useVirtualHistory.js' +import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import type { Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js' + +import { MAX_HISTORY, PASTE_SNIPPET_RE, STARTUP_RESUME_ID, WHEEL_SCROLL_STEP } from './constants.js' +import { createGatewayEventHandler } from './createGatewayEventHandler.js' +import { createSlashHandler } from './createSlashHandler.js' +import { + imageTokenMeta, + introMsg, + looksLikeSlashCommand, + resolveDetailsMode, + shortCwd, + toTranscriptMessages +} from './helpers.js' +import { type GatewayRpc, type TranscriptRow } from './interfaces.js' +import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { $uiState, getUiState, patchUiState } from './uiStore.js' +import { useComposerState } from './useComposerState.js' +import { useInputHandlers } from './useInputHandlers.js' +import { useLongRunToolCharms } from './useLongRunToolCharms.js' +import { useTurnState } from './useTurnState.js' + +const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i + +export function useMainApp(gw: GatewayClient) { + const { exit } = useApp() + const { stdout } = useStdout() + const [cols, setCols] = useState(stdout?.columns ?? 80) + + useEffect(() => { + if (!stdout) { + return + } + + const sync = () => setCols(stdout.columns ?? 80) + stdout.on('resize', sync) + + if (stdout.isTTY) { + stdout.write('\x1b[?2004h') + } + + return () => { + stdout.off('resize', sync) + + if (stdout.isTTY) { + stdout.write('\x1b[?2004l') + } + } + }, [stdout]) + + const [historyItems, setHistoryItems] = useState([]) + const [lastUserMsg, setLastUserMsg] = useState('') + const [stickyPrompt, setStickyPrompt] = useState('') + const [catalog, setCatalog] = useState(null) + const [voiceEnabled, setVoiceEnabled] = useState(false) + const [voiceRecording, setVoiceRecording] = useState(false) + const [voiceProcessing, setVoiceProcessing] = useState(false) + const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) + const [goodVibesTick, setGoodVibesTick] = useState(0) + const [bellOnComplete, setBellOnComplete] = useState(false) + const ui = useStore($uiState) + const overlay = useStore($overlayState) + const isBlocked = useStore($isBlocked) + + const slashFlightRef = useRef(0) + const slashRef = useRef<(cmd: string) => boolean>(() => false) + const lastEmptyAt = useRef(0) + const colsRef = useRef(cols) + const scrollRef = useRef(null) + const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) + const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) + const submitRef = useRef<(value: string) => void>(() => {}) + const configMtimeRef = useRef(0) + const historyItemsRef = useRef(historyItems) + const lastUserMsgRef = useRef(lastUserMsg) + const msgIdsRef = useRef(new WeakMap()) + const nextMsgIdRef = useRef(0) + colsRef.current = cols + historyItemsRef.current = historyItems + lastUserMsgRef.current = lastUserMsg + + const hasSelection = useHasSelection() + const selection = useSelection() + const turn = useTurnState() + const turnActions = turn.actions + const turnRefs = turn.refs + const turnState = turn.state + + const composer = useComposerState({ + gw, + onClipboardPaste: quiet => clipboardPasteRef.current(quiet), + submitRef + }) + + const composerActions = composer.actions + const composerRefs = composer.refs + const composerState = composer.state + + const empty = !historyItems.some(msg => msg.kind !== 'intro') + + const messageId = useCallback((msg: Msg) => { + const hit = msgIdsRef.current.get(msg) + + if (hit) { + return hit + } + + const next = `m${++nextMsgIdRef.current}` + msgIdsRef.current.set(msg, next) + + return next + }, []) + + const virtualRows = useMemo( + () => + historyItems.map((msg, index) => ({ + index, + key: messageId(msg), + msg + })), + [historyItems, messageId] + ) + + const virtualHistory = useVirtualHistory(scrollRef, virtualRows) + + const scrollWithSelection = useCallback( + (delta: number) => { + const s = scrollRef.current + + const sel = selection.getState() as { + anchor?: { row: number } + focus?: { row: number } + isDragging?: boolean + } | null + + if (!s || !sel?.anchor || !sel.focus) { + s?.scrollBy(delta) + + return + } + + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 + + if (sel.anchor.row < top || sel.anchor.row > bottom) { + s.scrollBy(delta) + + return + } + + if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { + s.scrollBy(delta) + + return + } + + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + const cur = s.getScrollTop() + s.getPendingDelta() + const actual = Math.max(0, Math.min(max, cur + delta)) - cur + + if (actual === 0) { + return + } + + if (actual > 0) { + selection.captureScrolledRows(top, top + actual - 1, 'above') + sel.isDragging ? selection.shiftAnchor(-actual, top, bottom) : selection.shiftSelection(-actual, top, bottom) + } else { + const amount = -actual + selection.captureScrolledRows(bottom - amount + 1, bottom, 'below') + sel.isDragging ? selection.shiftAnchor(amount, top, bottom) : selection.shiftSelection(amount, top, bottom) + } + + s.scrollBy(delta) + }, + [selection] + ) + + const appendMessage = useCallback((msg: Msg) => { + const cap = (items: Msg[]) => + items.length <= MAX_HISTORY + ? items + : items[0]?.kind === 'intro' + ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] + : items.slice(-MAX_HISTORY) + + setHistoryItems(prev => cap([...prev, msg])) + }, []) + + const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) + + const page = useCallback((text: string, title?: string) => { + const lines = text.split('\n') + patchOverlayState({ pager: { lines, offset: 0, title } }) + }, []) + + const panel = useCallback( + (title: string, sections: PanelSection[]) => { + appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) + }, + [appendMessage] + ) + + const maybeWarn = useCallback( + (value: any) => { + if (value?.warning) { + sys(`warning: ${value.warning}`) + } + }, + [sys] + ) + + const maybeGoodVibes = useCallback((text: string) => { + if (!GOOD_VIBES_RE.test(text)) { + return + } + + setGoodVibesTick(v => v + 1) + }, []) + + const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { + const display = cfg?.config?.display ?? {} + + setBellOnComplete(!!display?.bell_on_complete) + patchUiState({ + compact: !!display?.tui_compact, + detailsMode: resolveDetailsMode(display), + statusBar: display?.tui_statusbar !== false + }) + }, []) + + const rpc: GatewayRpc = useCallback( + async = Record>( + method: string, + params: Record = {} + ) => { + try { + const result = asRpcResult(await gw.request(method, params)) + + if (result) { + return result + } + + sys(`error: invalid response: ${method}`) + } catch (e) { + sys(`error: ${rpcErrorMessage(e)}`) + } + + return null + }, + [gw, sys] + ) + + const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + + useEffect(() => { + if (!ui.sid || !stdout) { + return + } + + const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) + stdout.on('resize', onResize) + + return () => { + stdout.off('resize', onResize) + } + }, [rpc, stdout, ui.sid]) + + const answerClarify = useCallback( + (answer: string) => { + const clarify = overlay.clarify + + if (!clarify) { + return + } + + const label = toolTrailLabel('clarify') + const nextTrail = turnRefs.turnToolsRef.current.filter(line => !sameToolTrailGroup(label, line)) + + turnRefs.turnToolsRef.current = nextTrail + turnActions.setTurnTrail(nextTrail) + + rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { + if (!r) { + return + } + + if (answer) { + turnRefs.persistedToolLabelsRef.current.add(label) + appendMessage({ + role: 'system', + text: '', + kind: 'trail', + tools: [buildToolTrailLine('clarify', clarify.question)] + }) + appendMessage({ role: 'user', text: answer }) + patchUiState({ status: 'running…' }) + } else { + sys('prompt cancelled') + } + + patchOverlayState({ clarify: null }) + }) + }, + [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs] + ) + + useEffect(() => { + if (!ui.sid) { + return + } + + rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) + rpc('config.get', { key: 'mtime' }).then(r => { + configMtimeRef.current = Number(r?.mtime ?? 0) + }) + rpc('config.get', { key: 'full' }).then(applyDisplayConfig) + }, [applyDisplayConfig, rpc, ui.sid]) + + useEffect(() => { + if (!ui.sid) { + return + } + + const id = setInterval(() => { + rpc('config.get', { key: 'mtime' }).then(r => { + const next = Number(r?.mtime ?? 0) + + if (configMtimeRef.current && next && next !== configMtimeRef.current) { + configMtimeRef.current = next + rpc('reload.mcp', { session_id: ui.sid }).then(r => { + if (!r) { + return + } + + turnActions.pushActivity('MCP reloaded after config change') + }) + rpc('config.get', { key: 'full' }).then(applyDisplayConfig) + } else if (!configMtimeRef.current && next) { + configMtimeRef.current = next + } + }) + }, 5000) + + return () => clearInterval(id) + }, [applyDisplayConfig, turnActions, rpc, ui.sid]) + + const idle = turnActions.idle + const clearReasoning = turnActions.clearReasoning + + const die = useCallback(() => { + gw.kill() + exit() + }, [exit, gw]) + + const resetSession = useCallback(() => { + idle() + clearReasoning() + setVoiceRecording(false) + setVoiceProcessing(false) + patchUiState({ + bgTasks: new Set(), + info: null, + sid: null, + usage: ZERO + }) + setHistoryItems([]) + setLastUserMsg('') + setStickyPrompt('') + composerActions.setPasteSnips([]) + turnActions.setActivity([]) + turnRefs.turnToolsRef.current = [] + turnRefs.lastStatusNoteRef.current = '' + turnRefs.protocolWarnedRef.current = false + turnRefs.persistedToolLabelsRef.current.clear() + }, [clearReasoning, composerActions, idle, turnActions, turnRefs]) + + const resetVisibleHistory = useCallback( + (info: SessionInfo | null = null) => { + idle() + clearReasoning() + setHistoryItems(info ? [introMsg(info)] : []) + patchUiState({ + info, + usage: info?.usage ? { ...ZERO, ...info.usage } : ZERO + }) + setStickyPrompt('') + composerActions.setPasteSnips([]) + turnActions.setActivity([]) + setLastUserMsg('') + turnRefs.turnToolsRef.current = [] + turnRefs.persistedToolLabelsRef.current.clear() + }, + [clearReasoning, composerActions, idle, turnActions, turnRefs] + ) + + const trimLastExchange = useCallback((items: Msg[]) => { + const q = [...items] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + if (q.at(-1)?.role === 'user') { + q.pop() + } + + return q + }, []) + + const guardBusySessionSwitch = useCallback( + (what = 'switch sessions') => { + if (!getUiState().busy) { + return false + } + + sys(`interrupt the current turn before trying to ${what}`) + + return true + }, + [sys] + ) + + const closeSession = useCallback( + (targetSid?: string | null) => { + if (!targetSid) { + return Promise.resolve(null) + } + + return rpc('session.close', { session_id: targetSid }) + }, + [rpc] + ) + + const newSession = useCallback( + async (msg?: string) => { + await closeSession(getUiState().sid) + + return rpc('session.create', { cols: colsRef.current }).then(r => { + if (!r) { + patchUiState({ status: 'ready' }) + + return + } + + resetSession() + setSessionStartedAt(Date.now()) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO + }) + + if (r.info) { + setHistoryItems([introMsg(r.info)]) + } + + if (r.info?.credential_warning) { + sys(`warning: ${r.info.credential_warning}`) + } + + if (msg) { + sys(msg) + } + }) + }, + [closeSession, resetSession, rpc, sys] + ) + + const resumeById = useCallback( + (id: string) => { + patchOverlayState({ picker: false }) + patchUiState({ status: 'resuming…' }) + closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.resume') + patchUiState({ status: 'ready' }) + + return + } + + resetSession() + setSessionStartedAt(Date.now()) + const resumed = toTranscriptMessages(r.messages) + + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO + }) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + ) + }, + [closeSession, gw, resetSession, sys] + ) + + const paste = useCallback( + (quiet = false) => + rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => { + if (!r) { + return + } + + if (r.attached) { + const meta = imageTokenMeta(r) + sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) + + return + } + + quiet || sys(r.message || 'No image found in clipboard') + }), + [rpc, sys] + ) + + clipboardPasteRef.current = paste + const handleTextPaste = composerActions.handleTextPaste + + const send = useCallback( + (text: string) => { + const expandPasteSnips = (value: string) => { + const byLabel = new Map() + + for (const item of composerState.pasteSnips) { + const list = byLabel.get(item.label) + list ? list.push(item.text) : byLabel.set(item.label, [item.text]) + } + + return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) + } + + const startSubmit = (displayText: string, submitText: string) => { + const sid = getUiState().sid + + if (!sid) { + sys('session not ready yet') + + return + } + + if (turnRefs.statusTimerRef.current) { + clearTimeout(turnRefs.statusTimerRef.current) + turnRefs.statusTimerRef.current = null + } + + maybeGoodVibes(submitText) + setLastUserMsg(text) + appendMessage({ role: 'user', text: displayText }) + patchUiState({ busy: true, status: 'running…' }) + turnRefs.bufRef.current = '' + turnRefs.interruptedRef.current = false + + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ busy: false, status: 'ready' }) + }) + } + + const sid = getUiState().sid + + if (!sid) { + sys('session not ready yet') + + return + } + + gw.request('input.detect_drop', { session_id: sid, text }) + .then((r: any) => { + if (r?.matched) { + if (r.is_image) { + const meta = imageTokenMeta(r) + turnActions.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + } else { + turnActions.pushActivity(`detected file: ${r.name}`) + } + + startSubmit(r.text || text, expandPasteSnips(r.text || text)) + + return + } + + startSubmit(text, expandPasteSnips(text)) + }) + .catch(() => startSubmit(text, expandPasteSnips(text))) + }, + [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs] + ) + + const shellExec = useCallback( + (cmd: string) => { + appendMessage({ role: 'user', text: `!${cmd}` }) + patchUiState({ busy: true, status: 'running…' }) + + gw.request('shell.exec', { command: cmd }) + .then((raw: any) => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: shell.exec') + + return + } + + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() + + if (out) { + sys(out) + } + + if (r.code !== 0 || !out) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => { + patchUiState({ busy: false, status: 'ready' }) + }) + }, + [appendMessage, gw, sys] + ) + + const openEditor = composerActions.openEditor + + const interpolate = useCallback( + (text: string, then: (result: string) => void) => { + patchUiState({ status: 'interpolating…' }) + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + + Promise.all( + matches.map(m => + gw + .request('shell.exec', { command: m[1]! }) + .then((raw: any) => { + const r = asRpcResult(raw) + + return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() + }) + .catch(() => '(error)') + ) + ).then(results => { + let out = text + + for (let i = matches.length - 1; i >= 0; i--) { + out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) + } + + then(out) + }) + }, + [gw] + ) + + const sendQueued = useCallback( + (text: string) => { + if (text.startsWith('!')) { + shellExec(text.slice(1).trim()) + + return + } + + if (hasInterpolation(text)) { + patchUiState({ busy: true }) + interpolate(text, send) + + return + } + + send(text) + }, + [interpolate, send, shellExec] + ) + + const dispatchSubmission = useCallback( + (full: string) => { + const live = getUiState() + + if (!full.trim()) { + return + } + + if (!live.sid) { + sys('session not ready yet') + + return + } + + if (looksLikeSlashCommand(full)) { + appendMessage({ role: 'system', text: full, kind: 'slash' }) + composerActions.pushHistory(full) + slashRef.current(full) + composerActions.clearIn() + + return + } + + if (full.startsWith('!')) { + composerActions.clearIn() + shellExec(full.slice(1).trim()) + + return + } + + const editIdx = composerRefs.queueEditRef.current + composerActions.clearIn() + + if (editIdx !== null) { + composerActions.replaceQueue(editIdx, full) + const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] + composerActions.syncQueue() + composerActions.setQueueEdit(null) + + if (picked && getUiState().busy && live.sid) { + composerRefs.queueRef.current.unshift(picked) + composerActions.syncQueue() + + return + } + + if (picked && live.sid) { + sendQueued(picked) + } + + return + } + + composerActions.pushHistory(full) + + if (getUiState().busy) { + composerActions.enqueue(full) + + return + } + + if (hasInterpolation(full)) { + patchUiState({ busy: true }) + interpolate(full, send) + + return + } + + send(full) + }, + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] + ) + + const { pagerPageSize } = useInputHandlers({ + actions: { + answerClarify, + appendMessage, + die, + dispatchSubmission, + guardBusySessionSwitch, + newSession, + sys + }, + composer: { + actions: composerActions, + refs: composerRefs, + state: composerState + }, + gateway, + terminal: { + hasSelection, + scrollRef, + scrollWithSelection, + selection, + stdout + }, + turn: { + actions: turnActions, + refs: turnRefs + }, + voice: { + recording: voiceRecording, + setProcessing: setVoiceProcessing, + setRecording: setVoiceRecording + }, + wheelStep: WHEEL_SCROLL_STEP + }) + + const onEvent = useMemo( + () => + createGatewayEventHandler({ + composer: { + dequeue: composerActions.dequeue, + queueEditRef: composerRefs.queueEditRef, + sendQueued + }, + gateway, + session: { + STARTUP_RESUME_ID, + colsRef, + newSession, + resetSession, + setCatalog + }, + system: { + bellOnComplete, + stdout, + sys + }, + transcript: { + appendMessage, + setHistoryItems + }, + turn: { + actions: { + clearReasoning, + endReasoningPhase: turnActions.endReasoningPhase, + idle, + pruneTransient: turnActions.pruneTransient, + pulseReasoningStreaming: turnActions.pulseReasoningStreaming, + pushActivity: turnActions.pushActivity, + pushTrail: turnActions.pushTrail, + scheduleReasoning: turnActions.scheduleReasoning, + scheduleStreaming: turnActions.scheduleStreaming, + setActivity: turnActions.setActivity, + setReasoningTokens: turnActions.setReasoningTokens, + setStreaming: turnActions.setStreaming, + setSubagents: turnActions.setSubagents, + setToolTokens: turnActions.setToolTokens, + setTools: turnActions.setTools, + setTurnTrail: turnActions.setTurnTrail + }, + refs: { + activeToolsRef: turnRefs.activeToolsRef, + bufRef: turnRefs.bufRef, + interruptedRef: turnRefs.interruptedRef, + lastStatusNoteRef: turnRefs.lastStatusNoteRef, + persistedToolLabelsRef: turnRefs.persistedToolLabelsRef, + protocolWarnedRef: turnRefs.protocolWarnedRef, + reasoningRef: turnRefs.reasoningRef, + statusTimerRef: turnRefs.statusTimerRef, + toolTokenAccRef: turnRefs.toolTokenAccRef, + toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, + turnToolsRef: turnRefs.turnToolsRef + } + } + }), + [ + appendMessage, + bellOnComplete, + clearReasoning, + composerActions, + composerRefs, + gateway, + idle, + newSession, + resetSession, + sendQueued, + sys, + turnActions, + turnRefs, + stdout + ] + ) + + onEventRef.current = onEvent + + useEffect(() => { + const handler = (ev: GatewayEvent) => onEventRef.current(ev) + + const exitHandler = () => { + patchUiState({ busy: false, sid: null, status: 'gateway exited' }) + turnActions.pushActivity('gateway exited · /logs to inspect', 'error') + sys('error: gateway exited') + } + + gw.on('event', handler) + gw.on('exit', exitHandler) + gw.drain() + + return () => { + gw.off('event', handler) + gw.off('exit', exitHandler) + gw.kill() + } + }, [gw, turnActions, sys]) + + useLongRunToolCharms(ui.busy, turnState.tools, turnActions.pushActivity) + + const slash = useMemo( + () => + createSlashHandler({ + slashFlightRef, + composer: { + enqueue: composerActions.enqueue, + hasSelection, + paste, + queueRef: composerRefs.queueRef, + selection, + setInput: composerActions.setInput + }, + gateway, + local: { + catalog, + getHistoryItems: () => historyItemsRef.current, + getLastUserMsg: () => lastUserMsgRef.current, + maybeWarn + }, + session: { + closeSession, + die, + guardBusySessionSwitch, + newSession, + resetVisibleHistory, + resumeById, + setSessionStartedAt + }, + transcript: { + page, + panel, + send, + setHistoryItems, + sys, + trimLastExchange + }, + voice: { + setVoiceEnabled + } + }), + [ + catalog, + closeSession, + composerActions, + composerRefs, + die, + gateway, + slashFlightRef, + guardBusySessionSwitch, + hasSelection, + maybeWarn, + newSession, + page, + panel, + paste, + resetVisibleHistory, + resumeById, + selection, + send, + setSessionStartedAt, + setHistoryItems, + setVoiceEnabled, + sys, + trimLastExchange + ] + ) + + slashRef.current = slash + + const submit = useCallback( + (value: string) => { + if (value.startsWith('/') && composerState.completions.length) { + const row = composerState.completions[composerState.compIdx] + + if (row?.text) { + const text = + value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0 + ? row.text.slice(1) + : row.text + + const next = value.slice(0, composerState.compReplace) + text + + if (next !== value) { + composerActions.setInput(next) + + return + } + } + } + + if (!value.trim() && !composerState.inputBuf.length) { + const live = getUiState() + const now = Date.now() + const dbl = now - lastEmptyAt.current < 450 + lastEmptyAt.current = now + + if (dbl && live.busy && live.sid) { + turnActions.interruptTurn({ + appendMessage, + gw, + sid: live.sid, + sys + }) + + return + } + + if (dbl && composerRefs.queueRef.current.length) { + const next = composerActions.dequeue() + + if (next && live.sid) { + composerActions.setQueueEdit(null) + dispatchSubmission(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + if (value.endsWith('\\')) { + composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) + composerActions.setInput('') + + return + } + + dispatchSubmission([...composerState.inputBuf, value].join('\n')) + }, + [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions] + ) + + submitRef.current = submit + + const statusColor = + ui.status === 'ready' + ? ui.theme.color.ok + : ui.status.startsWith('error') + ? ui.theme.color.error + : ui.status === 'interrupted' + ? ui.theme.color.warn + : ui.theme.color.dim + + const sessionStarted = ui.sid ? sessionStartedAt : null + const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` + const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) + const showStreamingArea = Boolean(turnState.streaming) + const showStickyPrompt = !!stickyPrompt + + const hasReasoning = Boolean(turnState.reasoning.trim()) + + const showProgressArea = + ui.detailsMode === 'hidden' + ? turnState.activity.some(item => item.tone !== 'info') + : Boolean( + ui.busy || + turnState.subagents.length || + turnState.tools.length || + turnState.turnTrail.length || + hasReasoning || + turnState.activity.length + ) + + const answerApproval = useCallback( + (choice: string) => { + rpc('approval.respond', { choice, session_id: ui.sid }).then(r => { + if (!r) { + return + } + + patchOverlayState({ approval: null }) + sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + patchUiState({ status: 'running…' }) + }) + }, + [rpc, sys, ui.sid] + ) + + const answerSudo = useCallback( + (pw: string) => { + if (!overlay.sudo) { + return + } + + rpc('sudo.respond', { request_id: overlay.sudo.requestId, password: pw }).then(r => { + if (!r) { + return + } + + patchOverlayState({ sudo: null }) + patchUiState({ status: 'running…' }) + }) + }, + [overlay.sudo, rpc] + ) + + const answerSecret = useCallback( + (value: string) => { + if (!overlay.secret) { + return + } + + rpc('secret.respond', { request_id: overlay.secret.requestId, value }).then(r => { + if (!r) { + return + } + + patchOverlayState({ secret: null }) + patchUiState({ status: 'running…' }) + }) + }, + [overlay.secret, rpc] + ) + + const onModelSelect = useCallback((value: string) => { + patchOverlayState({ modelPicker: false }) + slashRef.current(`/model ${value}`) + }, []) + + const appActions = useMemo( + () => ({ + answerApproval, + answerClarify, + answerSecret, + answerSudo, + onModelSelect, + resumeById, + setStickyPrompt + }), + [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt] + ) + + const appComposer = useMemo( + () => ({ + cols, + compIdx: composerState.compIdx, + completions: composerState.completions, + empty, + handleTextPaste, + input: composerState.input, + inputBuf: composerState.inputBuf, + pagerPageSize, + queueEditIdx: composerState.queueEditIdx, + queuedDisplay: composerState.queuedDisplay, + submit, + updateInput: composerActions.setInput + }), + [ + cols, + composerActions.setInput, + composerState.compIdx, + composerState.completions, + composerState.input, + composerState.inputBuf, + composerState.queueEditIdx, + composerState.queuedDisplay, + empty, + handleTextPaste, + pagerPageSize, + submit + ] + ) + + const appProgress = useMemo( + () => ({ + activity: turnState.activity, + reasoning: turnState.reasoning, + reasoningActive: turnState.reasoningActive, + reasoningStreaming: turnState.reasoningStreaming, + reasoningTokens: turnState.reasoningTokens, + showProgressArea, + showStreamingArea, + streaming: turnState.streaming, + subagents: turnState.subagents, + toolTokens: turnState.toolTokens, + tools: turnState.tools, + turnTrail: turnState.turnTrail + }), + [showProgressArea, showStreamingArea, turnState] + ) + + const appStatus = useMemo( + () => ({ + cwdLabel, + goodVibesTick, + sessionStartedAt: sessionStarted, + showStickyPrompt, + statusColor, + stickyPrompt, + voiceLabel + }), + [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] + ) + + const appTranscript = useMemo( + () => ({ + historyItems, + scrollRef, + virtualHistory, + virtualRows + }), + [historyItems, scrollRef, virtualHistory, virtualRows] + ) + + return { + appActions, + appComposer, + appProgress, + appStatus, + appTranscript, + gateway + } +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 2f40b33c9f..6eff78c58c 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -147,6 +147,11 @@ export interface SlashExecResponse { warning?: string } +export type CommandDispatchResponse = + | { output?: string; type: 'exec' | 'plugin' } + | { target: string; type: 'alias' } + | { message?: string; name: string; type: 'skill' } + export interface SubagentEventPayload { duration_seconds?: number goal: string diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index 8bfa7fe201..c2360dd0ca 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -1,3 +1,5 @@ +import type { CommandDispatchResponse } from '../gatewayTypes.js' + export type RpcResult = Record export const asRpcResult = (value: unknown): T | null => { @@ -8,6 +10,34 @@ export const asRpcResult = (value: unknown): T return value as T } +export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => { + const o = asRpcResult(value) + + if (!o || typeof o.type !== 'string') { + return null + } + + const t = o.type + + if (t === 'exec' || t === 'plugin') { + return { type: t, output: typeof o.output === 'string' ? o.output : undefined } + } + + if (t === 'alias' && typeof o.target === 'string') { + return { type: 'alias', target: o.target } + } + + if (t === 'skill' && typeof o.name === 'string') { + return { + type: 'skill', + name: o.name, + message: typeof o.message === 'string' ? o.message : undefined + } + } + + return null +} + export const rpcErrorMessage = (err: unknown) => { if (err instanceof Error && err.message) { return err.message From 39b1336d1fa033530262379f79be2a73a28c277f Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 08:27:41 -0500 Subject: [PATCH 118/157] fix: ctx usage display --- tui_gateway/server.py | 6 + .../createGatewayEventHandler.test.ts | 176 +++++++++++++++++- ui-tui/src/app/createGatewayEventHandler.ts | 6 +- ui-tui/src/gatewayTypes.ts | 6 +- 4 files changed, 190 insertions(+), 4 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index e3fb585135..8db15b6f67 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1342,6 +1342,7 @@ def _(rid, params: dict) -> dict: stream_callback=_stream, ) + last_reasoning = None if isinstance(result, dict): if isinstance(result.get("messages"), list): with session["history_lock"]: @@ -1350,11 +1351,16 @@ def _(rid, params: dict) -> dict: session["history_version"] = history_version + 1 raw = result.get("final_response", "") status = "interrupted" if result.get("interrupted") else "error" if result.get("error") else "complete" + lr = result.get("last_reasoning") + if isinstance(lr, str) and lr.strip(): + last_reasoning = lr.strip() else: raw = str(result) status = "complete" payload = {"text": raw, "usage": _get_usage(agent), "status": status} + if last_reasoning: + payload["reasoning"] = last_reasoning rendered = render_message(raw, cols) if rendered: payload["rendered"] = rendered diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index be27d5347f..c4f5628ca7 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' import { resetOverlayState } from '../app/overlayStore.js' -import { resetUiState } from '../app/uiStore.js' +import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' @@ -348,4 +348,178 @@ describe('createGatewayEventHandler', () => { expect(appended[0]?.thinking).toBe(streamed) expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(streamed)) }) + + it('uses message.complete reasoning when no streamed reasoning ref', () => { + const appended: Msg[] = [] + const fromServer = 'recovered from last_reasoning' + + const refs = { + activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), + bufRef: ref(''), + interruptedRef: ref(false), + lastStatusNoteRef: ref(''), + persistedToolLabelsRef: ref(new Set()), + protocolWarnedRef: ref(false), + reasoningRef: ref(''), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const onEvent = createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(null), + sendQueued: vi.fn() + }, + gateway: { + gw: { request: vi.fn() } as any, + rpc: vi.fn(async () => null) + }, + session: { + STARTUP_RESUME_ID: '', + colsRef: ref(80), + newSession: vi.fn(), + resetSession: vi.fn(), + setCatalog: vi.fn() + }, + system: { + bellOnComplete: false, + sys: vi.fn() + }, + transcript: { + appendMessage: (msg: Msg) => appended.push(msg), + setHistoryItems: vi.fn() + }, + turn: { + actions: { + clearReasoning: vi.fn(() => { + refs.reasoningRef.current = '' + refs.toolTokenAccRef.current = 0 + }), + endReasoningPhase: vi.fn(), + idle: vi.fn(() => { + refs.activeToolsRef.current = [] + }), + pruneTransient: vi.fn(), + pulseReasoningStreaming: vi.fn(), + pushActivity: vi.fn(), + pushTrail: vi.fn(), + scheduleReasoning: vi.fn(), + scheduleStreaming: vi.fn(), + setActivity: vi.fn(), + setReasoningTokens: vi.fn(), + setStreaming: vi.fn(), + setToolTokens: vi.fn(), + setTools: vi.fn(), + setTurnTrail: vi.fn() + }, + refs + } + } as any) + + onEvent({ + payload: { reasoning: fromServer, text: 'final answer' }, + type: 'message.complete' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]?.thinking).toBe(fromServer) + expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) + }) + + it('merges message.complete usage into existing context fields', () => { + const appended: Msg[] = [] + + patchUiState({ + usage: { + calls: 1, + context_max: 100_000, + context_percent: 12, + context_used: 12_000, + input: 10, + output: 20, + total: 30 + } + }) + + const refs = { + activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), + bufRef: ref(''), + interruptedRef: ref(false), + lastStatusNoteRef: ref(''), + persistedToolLabelsRef: ref(new Set()), + protocolWarnedRef: ref(false), + reasoningRef: ref(''), + statusTimerRef: ref | null>(null), + toolTokenAccRef: ref(0), + toolCompleteRibbonRef: ref(null), + turnToolsRef: ref([] as string[]) + } + + const onEvent = createGatewayEventHandler({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(null), + sendQueued: vi.fn() + }, + gateway: { + gw: { request: vi.fn() } as any, + rpc: vi.fn(async () => null) + }, + session: { + STARTUP_RESUME_ID: '', + colsRef: ref(80), + newSession: vi.fn(), + resetSession: vi.fn(), + setCatalog: vi.fn() + }, + system: { + bellOnComplete: false, + sys: vi.fn() + }, + transcript: { + appendMessage: (msg: Msg) => appended.push(msg), + setHistoryItems: vi.fn() + }, + turn: { + actions: { + clearReasoning: vi.fn(() => { + refs.reasoningRef.current = '' + }), + endReasoningPhase: vi.fn(), + idle: vi.fn(), + pruneTransient: vi.fn(), + pulseReasoningStreaming: vi.fn(), + pushActivity: vi.fn(), + pushTrail: vi.fn(), + scheduleReasoning: vi.fn(), + scheduleStreaming: vi.fn(), + setActivity: vi.fn(), + setReasoningTokens: vi.fn(), + setStreaming: vi.fn(), + setToolTokens: vi.fn(), + setTools: vi.fn(), + setTurnTrail: vi.fn() + }, + refs + } + } as any) + + onEvent({ + payload: { + text: 'ok', + usage: { calls: 2, input: 50, output: 60, total: 110 } + }, + type: 'message.complete' + } as any) + + const u = getUiState().usage + expect(u.input).toBe(50) + expect(u.total).toBe(110) + expect(u.context_max).toBe(100_000) + expect(u.context_used).toBe(12_000) + expect(u.context_percent).toBe(12) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 86bacdecb6..5541bf513f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -631,7 +631,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const p = ev.payload const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart() const persisted = persistedToolLabelsRef.current - const savedReasoning = reasoningRef.current.trim() + const streamedReasoning = reasoningRef.current.trim() + const payloadReasoning = String(p?.reasoning ?? '').trim() + const savedReasoning = streamedReasoning || payloadReasoning const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 const savedToolTokens = toolTokenAccRef.current @@ -666,7 +668,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: setStatus('ready') if (p?.usage) { - patchUiState({ usage: p.usage }) + patchUiState(state => ({ ...state, usage: { ...state.usage, ...p.usage } })) } if (queueEditRef.current !== null) { diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6eff78c58c..7fab065971 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -199,5 +199,9 @@ export type GatewayEvent = | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.progress' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' } | { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' } - | { payload?: { rendered?: string; text?: string; usage?: Usage }; session_id?: string; type: 'message.complete' } + | { + payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage } + session_id?: string + type: 'message.complete' + } | { payload?: { message?: string }; session_id?: string; type: 'error' } From c4b9750bc1d335ac2d13cead1f8926ab5663e79b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 10:47:37 -0500 Subject: [PATCH 119/157] feat: lazy bootstrap node --- README.md | 9 +- hermes_cli/main.py | 58 ++++++++- scripts/lib/node-bootstrap.sh | 238 ++++++++++++++++++++++++++++++++++ 3 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 scripts/lib/node-bootstrap.sh diff --git a/README.md b/README.md index 07a1404190..ab158fc2bd 100644 --- a/README.md +++ b/README.md @@ -141,11 +141,18 @@ See `hermes claw migrate --help` for all options, or use the `openclaw-migration We welcome contributions! See the [Contributing Guide](https://hermes-agent.nousresearch.com/docs/developer-guide/contributing) for development setup, code style, and PR process. -Quick start for contributors: +Quick start for contributors — clone and go with `setup-hermes.sh`: ```bash git clone https://github.com/NousResearch/hermes-agent.git cd hermes-agent +./setup-hermes.sh # installs uv, creates venv, installs .[all], symlinks ~/.local/bin/hermes +./hermes # auto-detects the venv, no need to `source` first +``` + +Manual path (equivalent to the above): + +```bash curl -LsSf https://astral.sh/uv/install.sh | sh uv venv venv --python 3.11 source venv/bin/activate diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 57926b1ceb..a9ad311877 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -789,8 +789,63 @@ def _hermes_ink_bundle_stale(tui_dir: Path) -> bool: return False +def _ensure_tui_node() -> None: + """Make sure `node` + `npm` are on PATH for the TUI. + + If either is missing and scripts/lib/node-bootstrap.sh is available, source + it and call `ensure_node` (fnm/nvm/proto/brew/bundled cascade). After + install, capture the resolved node binary path from the bash subprocess + and prepend its directory to os.environ["PATH"] so shutil.which finds the + new binaries in this Python process — regardless of which version manager + was used (nvm, fnm, proto, brew, or the bundled fallback). + + Idempotent no-op when node+npm are already discoverable. Set + ``HERMES_SKIP_NODE_BOOTSTRAP=1`` to disable auto-install. + """ + if shutil.which("node") and shutil.which("npm"): + return + if os.environ.get("HERMES_SKIP_NODE_BOOTSTRAP"): + return + + helper = PROJECT_ROOT / "scripts" / "lib" / "node-bootstrap.sh" + if not helper.is_file(): + return + + hermes_home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes") + try: + # Helper writes logs to stderr; we ask bash to print `command -v node` + # on stdout once ensure_node succeeds. Subshell PATH edits don't leak + # back into Python, so the stdout capture is the bridge. + result = subprocess.run( + ["bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node'], + env={**os.environ, "HERMES_HOME": hermes_home}, + capture_output=True, + text=True, + check=False, + ) + except (OSError, subprocess.SubprocessError): + return + + parts = os.environ.get("PATH", "").split(os.pathsep) + extras: list[Path] = [] + + resolved = (result.stdout or "").strip() + if resolved: + extras.append(Path(resolved).resolve().parent) + + extras.extend([Path(hermes_home) / "node" / "bin", Path.home() / ".local" / "bin"]) + + for extra in extras: + s = str(extra) + if extra.is_dir() and s not in parts: + parts.insert(0, s) + os.environ["PATH"] = os.pathsep.join(parts) + + def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """Ink TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" + _ensure_tui_node() + def _node_bin(bin: str)-> str: path = shutil.which(bin) if not path: @@ -809,7 +864,8 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: npm = _node_bin("npm") if _tui_need_npm_install(tui_dir): - print("Installing TUI dependencies…") + if not os.environ.get("HERMES_QUIET"): + print("Installing TUI dependencies…") result = subprocess.run( [npm, "install", "--silent", "--no-fund", "--no-audit", "--progress=false"], cwd=str(tui_dir), diff --git a/scripts/lib/node-bootstrap.sh b/scripts/lib/node-bootstrap.sh new file mode 100644 index 0000000000..9eadc479dd --- /dev/null +++ b/scripts/lib/node-bootstrap.sh @@ -0,0 +1,238 @@ +#!/usr/bin/env bash +# ============================================================================ +# scripts/lib/node-bootstrap.sh +# ---------------------------------------------------------------------------- +# Sourceable helper: ensure Node.js >= MIN_VERSION is available for the TUI +# (React + Ink), browser tools, and the WhatsApp bridge. +# +# Strategy (first hit wins — respects the user's existing tooling): +# 1. modern `node` already on PATH +# 2. ~/.hermes/node/ from a prior Hermes-managed install +# 3. fnm, proto, nvm (in that order) if the user already uses a version manager +# 4. Termux `pkg`, macOS Homebrew +# 5. pinned nodejs.org tarball into ~/.hermes/node/ (always works, zero shell rc edits) +# +# Usage: +# source scripts/lib/node-bootstrap.sh +# ensure_node # returns 0 on success, non-zero on failure +# if [ "$HERMES_NODE_AVAILABLE" = true ]; then ...; fi +# +# Env inputs (set before sourcing to override defaults): +# HERMES_NODE_MIN_VERSION (default: 20) — accepted on PATH +# HERMES_NODE_TARGET_MAJOR (default: 22) — installed when we install +# HERMES_HOME (default: $HOME/.hermes) +# ============================================================================ + +HERMES_NODE_MIN_VERSION="${HERMES_NODE_MIN_VERSION:-20}" +HERMES_NODE_TARGET_MAJOR="${HERMES_NODE_TARGET_MAJOR:-22}" +HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" +HERMES_NODE_AVAILABLE=false + +# --------------------------------------------------------------------------- +# Logging — prefer the host script's log_* helpers when present +# --------------------------------------------------------------------------- + +_nb_log() { declare -F log_info >/dev/null 2>&1 && log_info "$*" || printf '→ %s\n' "$*" >&2; } +_nb_ok() { declare -F log_success >/dev/null 2>&1 && log_success "$*" || printf '✓ %s\n' "$*" >&2; } +_nb_warn() { declare -F log_warn >/dev/null 2>&1 && log_warn "$*" || printf '⚠ %s\n' "$*" >&2; } + +# --------------------------------------------------------------------------- +# Platform + version helpers +# --------------------------------------------------------------------------- + +_nb_is_termux() { + [ -n "${TERMUX_VERSION:-}" ] || [[ "${PREFIX:-}" == *"com.termux/files/usr"* ]] +} + +_nb_node_major() { + local v + v=$(node --version 2>/dev/null | sed 's/^v//' | cut -d. -f1) + [[ "$v" =~ ^[0-9]+$ ]] && echo "$v" || echo 0 +} + +_nb_have_modern_node() { + command -v node >/dev/null 2>&1 || return 1 + [ "$(_nb_node_major)" -ge "$HERMES_NODE_MIN_VERSION" ] +} + +# --------------------------------------------------------------------------- +# Version-manager paths — respect what the user already uses +# --------------------------------------------------------------------------- + +_nb_try_fnm() { + command -v fnm >/dev/null 2>&1 || return 1 + _nb_log "fnm detected — installing Node $HERMES_NODE_TARGET_MAJOR..." + eval "$(fnm env 2>/dev/null)" || true + fnm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + fnm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) activated via fnm" + return 0 +} + +_nb_try_proto() { + command -v proto >/dev/null 2>&1 || return 1 + _nb_log "proto detected — installing Node $HERMES_NODE_TARGET_MAJOR..." + proto install node "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) activated via proto" + return 0 +} + +_nb_try_nvm() { + local nvm_sh="${NVM_DIR:-$HOME/.nvm}/nvm.sh" + [ -s "$nvm_sh" ] || return 1 + # shellcheck source=/dev/null + \. "$nvm_sh" >/dev/null 2>&1 || return 1 + _nb_log "nvm detected — installing Node $HERMES_NODE_TARGET_MAJOR..." + nvm install "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + nvm use "$HERMES_NODE_TARGET_MAJOR" >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) activated via nvm" + return 0 +} + +# --------------------------------------------------------------------------- +# Platform package managers +# --------------------------------------------------------------------------- + +_nb_try_termux_pkg() { + _nb_is_termux || return 1 + _nb_log "Installing Node.js via pkg..." + pkg install -y nodejs >/dev/null 2>&1 || return 1 + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) installed via pkg" + return 0 +} + +_nb_try_brew() { + [ "$(uname -s)" = "Darwin" ] || return 1 + command -v brew >/dev/null 2>&1 || return 1 + _nb_log "Installing Node via Homebrew..." + brew install "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 \ + || brew install node >/dev/null 2>&1 \ + || return 1 + brew link --overwrite --force "node@${HERMES_NODE_TARGET_MAJOR}" >/dev/null 2>&1 || true + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) installed via Homebrew" + return 0 +} + +# --------------------------------------------------------------------------- +# Bundled binary fallback — always works, no shell rc edits +# --------------------------------------------------------------------------- + +_nb_install_bundled_node() { + local arch node_arch os_name node_os + arch=$(uname -m) + case "$arch" in + x86_64) node_arch="x64" ;; + aarch64|arm64) node_arch="arm64" ;; + armv7l) node_arch="armv7l" ;; + *) + _nb_warn "Unsupported arch ($arch) — install Node.js manually: https://nodejs.org/" + return 1 + ;; + esac + + os_name=$(uname -s) + case "$os_name" in + Linux*) node_os="linux" ;; + Darwin*) node_os="darwin" ;; + *) + _nb_warn "Unsupported OS ($os_name) — install Node.js manually: https://nodejs.org/" + return 1 + ;; + esac + + local index_url="https://nodejs.org/dist/latest-v${HERMES_NODE_TARGET_MAJOR}.x/" + local tarball + tarball=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.xz" \ + | head -1) + if [ -z "$tarball" ]; then + tarball=$(curl -fsSL "$index_url" \ + | grep -oE "node-v${HERMES_NODE_TARGET_MAJOR}\.[0-9]+\.[0-9]+-${node_os}-${node_arch}\.tar\.gz" \ + | head -1) + fi + if [ -z "$tarball" ]; then + _nb_warn "Could not resolve Node $HERMES_NODE_TARGET_MAJOR binary for $node_os-$node_arch" + return 1 + fi + + local tmp + tmp=$(mktemp -d) + _nb_log "Downloading $tarball..." + curl -fsSL "${index_url}${tarball}" -o "$tmp/$tarball" || { + _nb_warn "Download failed"; rm -rf "$tmp"; return 1 + } + + _nb_log "Extracting to $HERMES_HOME/node/..." + if [[ "$tarball" == *.tar.xz ]]; then + tar xf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; } + else + tar xzf "$tmp/$tarball" -C "$tmp" || { rm -rf "$tmp"; return 1; } + fi + + local extracted + extracted=$(find "$tmp" -maxdepth 1 -type d -name 'node-v*' 2>/dev/null | head -1) + if [ ! -d "$extracted" ]; then + _nb_warn "Extraction produced no node-v* directory" + rm -rf "$tmp" + return 1 + fi + + mkdir -p "$HERMES_HOME" + rm -rf "$HERMES_HOME/node" + mv "$extracted" "$HERMES_HOME/node" + rm -rf "$tmp" + + mkdir -p "$HOME/.local/bin" + ln -sf "$HERMES_HOME/node/bin/node" "$HOME/.local/bin/node" + ln -sf "$HERMES_HOME/node/bin/npm" "$HOME/.local/bin/npm" + ln -sf "$HERMES_HOME/node/bin/npx" "$HOME/.local/bin/npx" + export PATH="$HERMES_HOME/node/bin:$PATH" + + _nb_have_modern_node || return 1 + _nb_ok "Node $(node --version) installed to $HERMES_HOME/node/" + return 0 +} + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +ensure_node() { + HERMES_NODE_AVAILABLE=false + + if _nb_have_modern_node; then + _nb_ok "Node $(node --version) found" + HERMES_NODE_AVAILABLE=true + return 0 + fi + + if [ -x "$HERMES_HOME/node/bin/node" ]; then + export PATH="$HERMES_HOME/node/bin:$PATH" + if _nb_have_modern_node; then + _nb_ok "Node $(node --version) found (Hermes-managed)" + HERMES_NODE_AVAILABLE=true + return 0 + fi + fi + + # Version managers first — respect the user's existing setup. + _nb_try_fnm && { HERMES_NODE_AVAILABLE=true; return 0; } + _nb_try_proto && { HERMES_NODE_AVAILABLE=true; return 0; } + _nb_try_nvm && { HERMES_NODE_AVAILABLE=true; return 0; } + + # Platform package managers. + _nb_try_termux_pkg && { HERMES_NODE_AVAILABLE=true; return 0; } + _nb_try_brew && { HERMES_NODE_AVAILABLE=true; return 0; } + + # Last resort: pinned nodejs.org tarball. + _nb_install_bundled_node && { HERMES_NODE_AVAILABLE=true; return 0; } + + _nb_warn "Node.js install failed — TUI and browser tools will be unavailable." + _nb_warn "Install manually: https://nodejs.org/en/download/ (or: \`brew install node\`, \`fnm install $HERMES_NODE_TARGET_MAJOR\`, etc.)" + return 1 +} From fc0623f0aff75b2db449dda51542c8a10ae642ae Mon Sep 17 00:00:00 2001 From: Ari Lotter Date: Thu, 16 Apr 2026 11:50:34 -0400 Subject: [PATCH 120/157] update nix --- nix/tui.nix | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nix/tui.nix b/nix/tui.nix index 93973019f5..70eb67f949 100644 --- a/nix/tui.nix +++ b/nix/tui.nix @@ -4,7 +4,7 @@ let src = ../ui-tui; npmDeps = pkgs.fetchNpmDeps { inherit src; - hash = "sha256-+EhRRuvXi5hJupseHblF+MGxs84ijRMIH4qt5+2yYi8="; + hash = "sha256-zsUPmbC6oMUO10EhS3ptvDjwlfpCSEmrkjyeORw7fac="; }; packageJson = builtins.fromJSON (builtins.readFile (src + "/package.json")); @@ -18,6 +18,11 @@ pkgs.buildNpmPackage { doCheck = false; + postPatch = '' + # fetchNpmDeps strips the trailing newline; match it so the diff passes + sed -i -z 's/\n$//' package-lock.json + ''; + installPhase = '' runHook preInstall From 68ecdb6e26a3f95aea6924f4d0d20763f41e3807 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 12:18:56 -0500 Subject: [PATCH 121/157] refactor(tui): store-driven turn state + slash registry + module split Hoist turn state from a 286-line hook into $turnState atom + turnController singleton. createGatewayEventHandler becomes a typed dispatch over the controller; its ctx shrinks from 30 fields to 5. Event-handler refs and 16 threaded actions are gone. Fold three createSlash*Handler factories into a data-driven SlashCommand[] registry under slash/commands/{core,session,ops}.ts. Aliases are data; findSlashCommand does name+alias lookup. Shared guarded/guardedErr combinator in slash/guarded.ts. Split constants.ts + app/helpers.ts into config/ (timing/limits/env), content/ (faces/placeholders/hotkeys/verbs/charms/fortunes), domain/ (roles/ details/messages/paths/slash/viewport/usage), protocol/ (interpolation/paste). Type every RPC response in gatewayTypes.ts (26 new interfaces); drop all `(r: any)` across slash + main app. Shrink useMainApp from 1216 -> 646 lines by extracting useSessionLifecycle, useSubmission, useConfigSync. Add themed primitive and strip ~50 `as any` color casts. Tests: 50 passing. Build + type-check clean. --- ui-tui/src/__tests__/constants.test.ts | 8 +- .../createGatewayEventHandler.test.ts | 472 +------- ui-tui/src/app.tsx | 2 +- ui-tui/src/app/constants.ts | 15 - ui-tui/src/app/createGatewayEventHandler.ts | 761 ++++-------- ui-tui/src/app/createSlashHandler.ts | 78 +- ui-tui/src/app/helpers.ts | 162 --- ui-tui/src/app/interfaces.ts | 156 +-- ui-tui/src/app/slash/commands/core.ts | 293 +++++ ui-tui/src/app/slash/commands/ops.ts | 368 ++++++ ui-tui/src/app/slash/commands/session.ts | 462 ++++++++ .../src/app/slash/createSlashCoreHandler.ts | 341 ------ ui-tui/src/app/slash/createSlashOpsHandler.ts | 456 -------- .../app/slash/createSlashSessionHandler.ts | 464 -------- ui-tui/src/app/slash/isStaleSlash.ts | 10 - ui-tui/src/app/slash/registry.ts | 18 + ui-tui/src/app/slash/shared.ts | 70 +- ui-tui/src/app/slash/types.ts | 24 + ui-tui/src/app/turnController.ts | 353 ++++++ ui-tui/src/app/turnStore.ts | 47 + ui-tui/src/app/uiStore.ts | 2 +- ui-tui/src/app/useComposerState.ts | 2 +- ui-tui/src/app/useConfigSync.ts | 84 ++ ui-tui/src/app/useInputHandlers.ts | 366 +++--- ui-tui/src/app/useLongRunToolCharms.ts | 42 +- ui-tui/src/app/useMainApp.ts | 1020 ++++------------- ui-tui/src/app/useSessionLifecycle.ts | 185 +++ ui-tui/src/app/useSubmission.ts | 300 +++++ ui-tui/src/app/useTurnState.ts | 286 ----- ui-tui/src/components/appChrome.tsx | 37 +- ui-tui/src/components/appLayout.tsx | 18 +- ui-tui/src/components/appOverlays.tsx | 10 +- ui-tui/src/components/messageLine.tsx | 6 +- ui-tui/src/components/themed.tsx | 45 + ui-tui/src/components/thinking.tsx | 44 +- ui-tui/src/config/env.ts | 5 + ui-tui/src/config/limits.ts | 5 + ui-tui/src/config/timing.ts | 2 + ui-tui/src/constants.ts | 101 -- ui-tui/src/content/charms.ts | 1 + ui-tui/src/content/faces.ts | 17 + ui-tui/src/content/fortunes.ts | 40 + ui-tui/src/content/hotkeys.ts | 19 + ui-tui/src/content/placeholders.ts | 13 + ui-tui/src/content/verbs.ts | 38 + ui-tui/src/domain/details.ts | 29 + ui-tui/src/domain/messages.ts | 102 ++ ui-tui/src/domain/paths.ts | 5 + ui-tui/src/domain/roles.ts | 9 + ui-tui/src/domain/slash.ts | 25 + ui-tui/src/domain/usage.ts | 3 + ui-tui/src/domain/viewport.ts | 44 + ui-tui/src/gatewayTypes.ts | 286 ++++- ui-tui/src/lib/text.ts | 24 +- ui-tui/src/protocol/interpolation.ts | 7 + ui-tui/src/protocol/paste.ts | 1 + 56 files changed, 3666 insertions(+), 4117 deletions(-) delete mode 100644 ui-tui/src/app/constants.ts delete mode 100644 ui-tui/src/app/helpers.ts create mode 100644 ui-tui/src/app/slash/commands/core.ts create mode 100644 ui-tui/src/app/slash/commands/ops.ts create mode 100644 ui-tui/src/app/slash/commands/session.ts delete mode 100644 ui-tui/src/app/slash/createSlashCoreHandler.ts delete mode 100644 ui-tui/src/app/slash/createSlashOpsHandler.ts delete mode 100644 ui-tui/src/app/slash/createSlashSessionHandler.ts delete mode 100644 ui-tui/src/app/slash/isStaleSlash.ts create mode 100644 ui-tui/src/app/slash/registry.ts create mode 100644 ui-tui/src/app/slash/types.ts create mode 100644 ui-tui/src/app/turnController.ts create mode 100644 ui-tui/src/app/turnStore.ts create mode 100644 ui-tui/src/app/useConfigSync.ts create mode 100644 ui-tui/src/app/useSessionLifecycle.ts create mode 100644 ui-tui/src/app/useSubmission.ts delete mode 100644 ui-tui/src/app/useTurnState.ts create mode 100644 ui-tui/src/components/themed.tsx create mode 100644 ui-tui/src/config/env.ts create mode 100644 ui-tui/src/config/limits.ts create mode 100644 ui-tui/src/config/timing.ts delete mode 100644 ui-tui/src/constants.ts create mode 100644 ui-tui/src/content/charms.ts create mode 100644 ui-tui/src/content/faces.ts create mode 100644 ui-tui/src/content/fortunes.ts create mode 100644 ui-tui/src/content/hotkeys.ts create mode 100644 ui-tui/src/content/placeholders.ts create mode 100644 ui-tui/src/content/verbs.ts create mode 100644 ui-tui/src/domain/details.ts create mode 100644 ui-tui/src/domain/messages.ts create mode 100644 ui-tui/src/domain/paths.ts create mode 100644 ui-tui/src/domain/roles.ts create mode 100644 ui-tui/src/domain/slash.ts create mode 100644 ui-tui/src/domain/usage.ts create mode 100644 ui-tui/src/domain/viewport.ts create mode 100644 ui-tui/src/protocol/interpolation.ts create mode 100644 ui-tui/src/protocol/paste.ts diff --git a/ui-tui/src/__tests__/constants.test.ts b/ui-tui/src/__tests__/constants.test.ts index 36ca9a0ad6..d069d24c2d 100644 --- a/ui-tui/src/__tests__/constants.test.ts +++ b/ui-tui/src/__tests__/constants.test.ts @@ -1,6 +1,12 @@ import { describe, expect, it } from 'vitest' -import { FACES, HOTKEYS, INTERPOLATION_RE, PLACEHOLDERS, ROLE, TOOL_VERBS, VERBS, ZERO } from '../constants.js' +import { FACES } from '../content/faces.js' +import { HOTKEYS } from '../content/hotkeys.js' +import { PLACEHOLDERS } from '../content/placeholders.js' +import { TOOL_VERBS, VERBS } from '../content/verbs.js' +import { ROLE } from '../domain/roles.js' +import { ZERO } from '../domain/usage.js' +import { INTERPOLATION_RE } from '../protocol/interpolation.js' import { DEFAULT_THEME } from '../theme.js' describe('constants', () => { diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index c4f5628ca7..63675b8d3b 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -2,115 +2,55 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createGatewayEventHandler } from '../app/createGatewayEventHandler.js' import { resetOverlayState } from '../app/overlayStore.js' -import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' +import { turnController } from '../app/turnController.js' +import { resetTurnState } from '../app/turnStore.js' +import { resetUiState } from '../app/uiStore.js' import { estimateTokensRough } from '../lib/text.js' import type { Msg } from '../types.js' const ref = (current: T) => ({ current }) +const buildCtx = (appended: Msg[]) => + ({ + composer: { + dequeue: () => undefined, + queueEditRef: ref(null), + sendQueued: vi.fn() + }, + gateway: { + gw: { request: vi.fn() }, + rpc: vi.fn(async () => null) + }, + session: { + STARTUP_RESUME_ID: '', + colsRef: ref(80), + newSession: vi.fn(), + resetSession: vi.fn(), + setCatalog: vi.fn() + }, + system: { + bellOnComplete: false, + sys: vi.fn() + }, + transcript: { + appendMessage: (msg: Msg) => appended.push(msg), + setHistoryItems: vi.fn() + } + }) as any + describe('createGatewayEventHandler', () => { beforeEach(() => { resetOverlayState() resetUiState() + resetTurnState() + turnController.fullReset() }) it('persists completed tool rows when message.complete lands immediately after tool.complete', () => { const appended: Msg[] = [] - const state = { - activity: [] as unknown[], - reasoningTokens: 0, - streaming: '', - toolTokens: 0, - tools: [] as unknown[], - turnTrail: [] as string[] - } - - const setTools = vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.tools = next as unknown[] - } - }) - - const setTurnTrail = vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.turnTrail = next as string[] - } - }) - - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref('mapped the page'), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } - - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - state.toolTokens = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - state.tools = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn((next: number) => { - state.reasoningTokens = next - }), - setStreaming: vi.fn((next: string) => { - state.streaming = next - }), - setToolTokens: vi.fn((next: number) => { - state.toolTokens = next - }), - setTools, - setTurnTrail - }, - refs - } - } as any) + turnController.reasoningText = 'mapped the page' + const onEvent = createGatewayEventHandler(buildCtx(appended)) onEvent({ payload: { context: 'home page', name: 'search', tool_id: 'tool-1' }, @@ -143,104 +83,14 @@ describe('createGatewayEventHandler', () => { it('keeps tool tokens across handler recreation mid-turn', () => { const appended: Msg[] = [] - const state = { - activity: [] as unknown[], - reasoningTokens: 0, - streaming: '', - toolTokens: 0, - tools: [] as unknown[], - turnTrail: [] as string[] - } + turnController.reasoningText = 'mapped the page' - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref('mapped the page'), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } - - const buildHandler = () => - createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - state.toolTokens = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - state.tools = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn((next: number) => { - state.reasoningTokens = next - }), - setStreaming: vi.fn((next: string) => { - state.streaming = next - }), - setToolTokens: vi.fn((next: number) => { - state.toolTokens = next - }), - setTools: vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.tools = next as unknown[] - } - }), - setTurnTrail: vi.fn((next: unknown) => { - if (typeof next !== 'function') { - state.turnTrail = next as string[] - } - }) - }, - refs - } - } as any) - - buildHandler()({ + createGatewayEventHandler(buildCtx(appended))({ payload: { context: 'home page', name: 'search', tool_id: 'tool-1' }, type: 'tool.start' } as any) - const onEvent = buildHandler() + const onEvent = createGatewayEventHandler(buildCtx(appended)) onEvent({ payload: { name: 'search', preview: 'hero cards' }, @@ -265,84 +115,11 @@ describe('createGatewayEventHandler', () => { const streamed = 'short streamed reasoning' const fallback = 'x'.repeat(400) - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref(''), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } + const onEvent = createGatewayEventHandler(buildCtx(appended)) - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn(), - setStreaming: vi.fn(), - setToolTokens: vi.fn(), - setTools: vi.fn(), - setTurnTrail: vi.fn() - }, - refs - } - } as any) - - onEvent({ - payload: { text: streamed }, - type: 'reasoning.delta' - } as any) - onEvent({ - payload: { text: fallback }, - type: 'reasoning.available' - } as any) - onEvent({ - payload: { text: 'final answer' }, - type: 'message.complete' - } as any) + onEvent({ payload: { text: streamed }, type: 'reasoning.delta' } as any) + onEvent({ payload: { text: fallback }, type: 'reasoning.available' } as any) + onEvent({ payload: { text: 'final answer' }, type: 'message.complete' } as any) expect(appended).toHaveLength(1) expect(appended[0]?.thinking).toBe(streamed) @@ -353,173 +130,12 @@ describe('createGatewayEventHandler', () => { const appended: Msg[] = [] const fromServer = 'recovered from last_reasoning' - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref(''), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } + const onEvent = createGatewayEventHandler(buildCtx(appended)) - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - refs.toolTokenAccRef.current = 0 - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(() => { - refs.activeToolsRef.current = [] - }), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn(), - setStreaming: vi.fn(), - setToolTokens: vi.fn(), - setTools: vi.fn(), - setTurnTrail: vi.fn() - }, - refs - } - } as any) - - onEvent({ - payload: { reasoning: fromServer, text: 'final answer' }, - type: 'message.complete' - } as any) + onEvent({ payload: { reasoning: fromServer, text: 'final answer' }, type: 'message.complete' } as any) expect(appended).toHaveLength(1) expect(appended[0]?.thinking).toBe(fromServer) expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) }) - - it('merges message.complete usage into existing context fields', () => { - const appended: Msg[] = [] - - patchUiState({ - usage: { - calls: 1, - context_max: 100_000, - context_percent: 12, - context_used: 12_000, - input: 10, - output: 20, - total: 30 - } - }) - - const refs = { - activeToolsRef: ref([] as { context?: string; id: string; name: string; startedAt?: number }[]), - bufRef: ref(''), - interruptedRef: ref(false), - lastStatusNoteRef: ref(''), - persistedToolLabelsRef: ref(new Set()), - protocolWarnedRef: ref(false), - reasoningRef: ref(''), - statusTimerRef: ref | null>(null), - toolTokenAccRef: ref(0), - toolCompleteRibbonRef: ref(null), - turnToolsRef: ref([] as string[]) - } - - const onEvent = createGatewayEventHandler({ - composer: { - dequeue: () => undefined, - queueEditRef: ref(null), - sendQueued: vi.fn() - }, - gateway: { - gw: { request: vi.fn() } as any, - rpc: vi.fn(async () => null) - }, - session: { - STARTUP_RESUME_ID: '', - colsRef: ref(80), - newSession: vi.fn(), - resetSession: vi.fn(), - setCatalog: vi.fn() - }, - system: { - bellOnComplete: false, - sys: vi.fn() - }, - transcript: { - appendMessage: (msg: Msg) => appended.push(msg), - setHistoryItems: vi.fn() - }, - turn: { - actions: { - clearReasoning: vi.fn(() => { - refs.reasoningRef.current = '' - }), - endReasoningPhase: vi.fn(), - idle: vi.fn(), - pruneTransient: vi.fn(), - pulseReasoningStreaming: vi.fn(), - pushActivity: vi.fn(), - pushTrail: vi.fn(), - scheduleReasoning: vi.fn(), - scheduleStreaming: vi.fn(), - setActivity: vi.fn(), - setReasoningTokens: vi.fn(), - setStreaming: vi.fn(), - setToolTokens: vi.fn(), - setTools: vi.fn(), - setTurnTrail: vi.fn() - }, - refs - } - } as any) - - onEvent({ - payload: { - text: 'ok', - usage: { calls: 2, input: 50, output: 60, total: 110 } - }, - type: 'message.complete' - } as any) - - const u = getUiState().usage - expect(u.input).toBe(50) - expect(u.total).toBe(110) - expect(u.context_max).toBe(100_000) - expect(u.context_used).toBe(12_000) - expect(u.context_percent).toBe(12) - }) }) diff --git a/ui-tui/src/app.tsx b/ui-tui/src/app.tsx index 4968d74c29..631bd7a350 100644 --- a/ui-tui/src/app.tsx +++ b/ui-tui/src/app.tsx @@ -1,7 +1,7 @@ -import { MOUSE_TRACKING } from './app/constants.js' import { GatewayProvider } from './app/gatewayContext.js' import { useMainApp } from './app/useMainApp.js' import { AppLayout } from './components/appLayout.js' +import { MOUSE_TRACKING } from './config/env.js' import type { GatewayClient } from './gatewayClient.js' export function App({ gw }: { gw: GatewayClient }) { diff --git a/ui-tui/src/app/constants.ts b/ui-tui/src/app/constants.ts deleted file mode 100644 index 335e58d82f..0000000000 --- a/ui-tui/src/app/constants.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { PLACEHOLDERS } from '../constants.js' -import { pick } from '../lib/text.js' - -export const PLACEHOLDER = pick(PLACEHOLDERS) -export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() - -export const LARGE_PASTE = { chars: 8000, lines: 80 } -export const MAX_HISTORY = 800 -export const REASONING_PULSE_MS = 700 -export const STREAM_BATCH_MS = 16 -export const WHEEL_SCROLL_STEP = 3 -export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test( - (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase() -) -export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 5541bf513f..f2e08765e6 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,21 +1,43 @@ -import type { CommandsCatalogResponse, GatewayEvent, SessionResumeResponse } from '../gatewayTypes.js' +import { STREAM_BATCH_MS } from '../config/timing.js' +import { introMsg, toTranscriptMessages } from '../domain/messages.js' +import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin, SessionResumeResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import { - buildToolTrailLine, - estimateTokensRough, - formatToolCall, - isToolTrailResultLine, - sameToolTrailGroup, - toolTrailLabel -} from '../lib/text.js' +import { formatToolCall } from '../lib/text.js' import { fromSkin } from '../theme.js' +import type { SubagentProgress } from '../types.js' -import { STREAM_BATCH_MS } from './constants.js' -import { introMsg, toTranscriptMessages } from './helpers.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' +const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i + +const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') + +const applySkin = (s: GatewaySkin) => + patchUiState({ theme: fromSkin(s.colors ?? {}, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '') }) + +const dropBgTask = (taskId: string) => + patchUiState(state => { + const next = new Set(state.bgTasks) + next.delete(taskId) + + return { ...state, bgTasks: next } + }) + +const statusToneFrom = (kind: string): 'error' | 'info' | 'warn' => + kind === 'error' ? 'error' : kind === 'warn' || kind === 'approval' ? 'warn' : 'info' + +const pushUnique = + (max: number) => + (xs: T[], x: T): T[] => + xs.at(-1) === x ? xs : [...xs, x].slice(-max) + +const pushThinking = pushUnique(6) +const pushNote = pushUnique(6) +const pushTool = pushUnique(8) + export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { const { dequeue, queueEditRef, sendQueued } = ctx.composer const { gw, rpc } = ctx.gateway @@ -23,53 +45,17 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: const { bellOnComplete, stdout, sys } = ctx.system const { appendMessage, setHistoryItems } = ctx.transcript - const { - clearReasoning, - endReasoningPhase, - idle, - pruneTransient, - pulseReasoningStreaming, - pushActivity, - pushTrail, - scheduleReasoning, - scheduleStreaming, - setActivity, - setStreaming, - setSubagents, - setToolTokens, - setTools, - setTurnTrail - } = ctx.turn.actions - - const { - activeToolsRef, - bufRef, - interruptedRef, - lastStatusNoteRef, - persistedToolLabelsRef, - protocolWarnedRef, - reasoningRef, - statusTimerRef, - toolTokenAccRef, - toolCompleteRibbonRef, - turnToolsRef - } = ctx.turn.refs - let pendingThinkingStatus = '' - let thinkingStatusTimer: ReturnType | null = null - let toolProgressTimer: ReturnType | null = null + let thinkingStatusTimer: null | ReturnType = null - const cancelThinkingStatus = () => { + const setStatus = (status: string) => { pendingThinkingStatus = '' if (thinkingStatusTimer) { clearTimeout(thinkingStatusTimer) thinkingStatusTimer = null } - } - const setStatus = (status: string) => { - cancelThinkingStatus() patchUiState({ status }) } @@ -82,79 +68,77 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: thinkingStatusTimer = setTimeout(() => { thinkingStatusTimer = null - patchUiState({ status: pendingThinkingStatus || (getUiState().busy ? 'running…' : 'ready') }) + patchUiState({ status: pendingThinkingStatus || statusFromBusy() }) }, STREAM_BATCH_MS) } - const scheduleToolProgress = () => { - if (toolProgressTimer) { + const restoreStatusAfter = (ms: number) => { + turnController.clearStatusTimer() + turnController.statusTimer = setTimeout(() => { + turnController.statusTimer = null + patchUiState({ status: statusFromBusy() }) + }, ms) + } + + const keepCompletedElseRunning = (s: SubagentProgress['status']) => (s === 'completed' ? s : 'running') + + const handleReady = (skin?: GatewaySkin) => { + if (skin) { + applySkin(skin) + } + + rpc('commands.catalog', {}) + .then(r => { + if (!r?.pairs) { + return + } + + setCatalog({ + canon: (r.canon ?? {}) as Record, + categories: r.categories ?? [], + pairs: r.pairs as [string, string][], + skillCount: (r.skill_count ?? 0) as number, + sub: (r.sub ?? {}) as Record + }) + + if (r.warning) { + turnController.pushActivity(String(r.warning), 'warn') + } + }) + .catch((e: unknown) => turnController.pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) + + if (!STARTUP_RESUME_ID) { + patchUiState({ status: 'forging session…' }) + newSession() + return } - toolProgressTimer = setTimeout(() => { - toolProgressTimer = null - setTools([...activeToolsRef.current]) - }, STREAM_BATCH_MS) - } + patchUiState({ status: 'resuming…' }) + gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) + .then(raw => { + const r = asRpcResult(raw) - const upsertSubagent = ( - taskIndex: number, - taskCount: number, - goal: string, - update: (current: { - durationSeconds?: number - goal: string - id: string - index: number - notes: string[] - status: 'completed' | 'failed' | 'interrupted' | 'running' - summary?: string - taskCount: number - thinking: string[] - tools: string[] - }) => { - durationSeconds?: number - goal: string - id: string - index: number - notes: string[] - status: 'completed' | 'failed' | 'interrupted' | 'running' - summary?: string - taskCount: number - thinking: string[] - tools: string[] - } - ) => { - const id = `sa:${taskIndex}:${goal || 'subagent'}` + if (!r) { + throw new Error('invalid response: session.resume') + } - setSubagents(prev => { - const index = prev.findIndex(item => item.id === id) - - const base = - index >= 0 - ? prev[index]! - : { - id, - index: taskIndex, - taskCount, - goal, - notes: [], - status: 'running' as const, - thinking: [], - tools: [] - } - - const nextItem = update(base) - - if (index < 0) { - return [...prev, nextItem].sort((a, b) => a.index - b.index) - } - - const next = [...prev] - next[index] = nextItem - - return next - }) + resetSession() + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: r.info?.usage ?? getUiState().usage + }) + setHistoryItems( + r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages) + ) + }) + .catch((e: unknown) => { + sys(`resume failed: ${rpcErrorMessage(e)}`) + patchUiState({ status: 'forging session…' }) + newSession('started a new session') + }) } return (ev: GatewayEvent) => { @@ -165,483 +149,240 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } switch (ev.type) { - case 'gateway.ready': { - const p = ev.payload + case 'gateway.ready': + handleReady(ev.payload?.skin) - if (p?.skin) { - patchUiState({ - theme: fromSkin( - p.skin.colors ?? {}, - p.skin.branding ?? {}, - p.skin.banner_logo ?? '', - p.skin.banner_hero ?? '' - ) - }) + return + + case 'skin.changed': + if (ev.payload) { + applySkin(ev.payload) } - rpc('commands.catalog', {}) - .then(r => { - if (!r?.pairs) { - return - } - - setCatalog({ - canon: (r.canon ?? {}) as Record, - categories: r.categories ?? [], - pairs: r.pairs as [string, string][], - skillCount: (r.skill_count ?? 0) as number, - sub: (r.sub ?? {}) as Record - }) - - if (r.warning) { - pushActivity(String(r.warning), 'warn') - } - }) - .catch((e: unknown) => pushActivity(`command catalog unavailable: ${rpcErrorMessage(e)}`, 'warn')) - - if (STARTUP_RESUME_ID) { - patchUiState({ status: 'resuming…' }) - gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) - .then(raw => { - const r = asRpcResult(raw) - - if (!r) { - throw new Error('invalid response: session.resume') - } - - resetSession() - const resumed = toTranscriptMessages(r.messages) - - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ?? getUiState().usage - }) - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - }) - .catch((e: unknown) => { - sys(`resume failed: ${rpcErrorMessage(e)}`) - patchUiState({ status: 'forging session…' }) - newSession('started a new session') - }) - } else { - patchUiState({ status: 'forging session…' }) - newSession() - } - - break - } - - case 'skin.changed': { - const p = ev.payload - - if (p) { - patchUiState({ - theme: fromSkin(p.colors ?? {}, p.branding ?? {}, p.banner_logo ?? '', p.banner_hero ?? '') - }) - } - - break - } - - case 'session.info': { - const p = ev.payload + return + case 'session.info': patchUiState(state => ({ ...state, - info: p, - usage: p.usage ? { ...state.usage, ...p.usage } : state.usage + info: ev.payload, + usage: ev.payload.usage ? { ...state.usage, ...ev.payload.usage } : state.usage })) - break - } - + return case 'thinking.delta': { - const p = ev.payload + const text = ev.payload?.text - if (p && Object.prototype.hasOwnProperty.call(p, 'text')) { - scheduleThinkingStatus(p.text ? String(p.text) : getUiState().busy ? 'running…' : 'ready') + if (text !== undefined) { + scheduleThinkingStatus(text ? String(text) : statusFromBusy()) } - break + return } case 'message.start': - patchUiState({ busy: true }) - endReasoningPhase() - clearReasoning() - setActivity([]) - setSubagents([]) - setTurnTrail([]) - activeToolsRef.current = [] - setTools([]) - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - toolTokenAccRef.current = 0 - setToolTokens(0) + turnController.startMessage() - break + return case 'status.update': { const p = ev.payload - if (p?.text) { - setStatus(p.text) - - if (p.kind && p.kind !== 'status') { - if (lastStatusNoteRef.current !== p.text) { - lastStatusNoteRef.current = p.text - pushActivity( - p.text, - p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' - ) - } - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - patchUiState({ status: getUiState().busy ? 'running…' : 'ready' }) - }, 4000) - } + if (!p?.text) { + return } - break + setStatus(p.text) + + if (!p.kind || p.kind === 'status') { + return + } + + if (turnController.lastStatusNote !== p.text) { + turnController.lastStatusNote = p.text + turnController.pushActivity(p.text, statusToneFrom(p.kind)) + } + + restoreStatusAfter(4000) + + return } case 'gateway.stderr': { - const p = ev.payload + const line = String(ev.payload.line).slice(0, 120) - if (p?.line) { - const line = String(p.line).slice(0, 120) - const tone = /\b(error|traceback|exception|failed|spawn)\b/i.test(line) ? 'error' : 'warn' + turnController.pushActivity(line, ERRLIKE_RE.test(line) ? 'error' : 'warn') - pushActivity(line, tone) - } - - break + return } case 'gateway.start_timeout': { - const p = ev.payload + const { cwd, python } = ev.payload ?? {} + const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : '' setStatus('gateway startup timeout') - pushActivity( - `gateway startup timed out${p?.python || p?.cwd ? ` · ${String(p?.python || '')} ${String(p?.cwd || '')}`.trim() : ''} · /logs to inspect`, - 'error' - ) + turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error') - break + return } - case 'gateway.protocol_error': { - const p = ev.payload - + case 'gateway.protocol_error': setStatus('protocol warning') + restoreStatusAfter(4000) - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) + if (!turnController.protocolWarned) { + turnController.protocolWarned = true + turnController.pushActivity('protocol noise detected · /logs to inspect', 'warn') } - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - patchUiState({ status: getUiState().busy ? 'running…' : 'ready' }) - }, 4000) - - if (!protocolWarnedRef.current) { - protocolWarnedRef.current = true - pushActivity('protocol noise detected · /logs to inspect', 'warn') + if (ev.payload?.preview) { + turnController.pushActivity(`protocol noise: ${String(ev.payload.preview).slice(0, 120)}`, 'warn') } - if (p?.preview) { - pushActivity(`protocol noise: ${String(p.preview).slice(0, 120)}`, 'warn') + return + + case 'reasoning.delta': + if (ev.payload?.text) { + turnController.recordReasoningDelta(ev.payload.text) } - break - } + return - case 'reasoning.delta': { - const p = ev.payload + case 'reasoning.available': + turnController.recordReasoningAvailable(String(ev.payload?.text ?? '')) - if (p?.text) { - reasoningRef.current += p.text - scheduleReasoning() - pulseReasoningStreaming() + return + + case 'tool.progress': + if (ev.payload?.preview && ev.payload.name) { + turnController.recordToolProgress(ev.payload.name, ev.payload.preview) } - break - } + return - case 'reasoning.available': { - const p = ev.payload - const incoming = String(p?.text ?? '').trim() - - if (!incoming) { - break + case 'tool.generating': + if (ev.payload?.name) { + turnController.pushTrail(`drafting ${ev.payload.name}…`) } - const current = reasoningRef.current.trim() + return - // `reasoning.available` is a backend fallback preview that can arrive after - // streamed reasoning. Preserve the live-visible reasoning/counts if we - // already saw deltas; only hydrate from this event when streaming gave us - // nothing. - if (!current) { - reasoningRef.current = incoming - scheduleReasoning() - pulseReasoningStreaming() + case 'tool.start': + turnController.recordToolStart(ev.payload.tool_id, ev.payload.name ?? 'tool', ev.payload.context ?? '') + + return + + case 'tool.complete': + turnController.recordToolComplete(ev.payload.tool_id, ev.payload.name, ev.payload.error, ev.payload.summary) + + if (ev.payload.inline_diff) { + sys(ev.payload.inline_diff) } - break - } + return - case 'tool.progress': { - const p = ev.payload - - if (p?.preview) { - const index = activeToolsRef.current.findIndex(tool => tool.name === p.name) - - if (index >= 0) { - const next = [...activeToolsRef.current] - - next[index] = { ...next[index]!, context: p.preview as string } - activeToolsRef.current = next - scheduleToolProgress() - } - } - - break - } - - case 'tool.generating': { - const p = ev.payload - - if (p?.name) { - pushTrail(`drafting ${p.name}…`) - } - - break - } - - case 'tool.start': { - const p = ev.payload - pruneTransient() - endReasoningPhase() - const name = p.name ?? 'tool' - const ctx = p.context ?? '' - const sample = `${String(p.name ?? '')} ${ctx}`.trim() - toolTokenAccRef.current += sample ? estimateTokensRough(sample) : 0 - setToolTokens(toolTokenAccRef.current) - activeToolsRef.current = [ - ...activeToolsRef.current, - { id: p.tool_id, name, context: ctx, startedAt: Date.now() } - ] - setTools(activeToolsRef.current) - - break - } - - case 'tool.complete': { - const p = ev.payload - toolCompleteRibbonRef.current = null - const done = activeToolsRef.current.find(tool => tool.id === p.tool_id) - const name = done?.name ?? p.name ?? 'tool' - const label = toolTrailLabel(name) - - const line = buildToolTrailLine(name, done?.context || '', !!p.error, p.error || p.summary || '') - - const next = [...turnToolsRef.current.filter(item => !sameToolTrailGroup(label, item)), line] - - activeToolsRef.current = activeToolsRef.current.filter(tool => tool.id !== p.tool_id) - setTools(activeToolsRef.current) - toolCompleteRibbonRef.current = { label, line } - - if (!activeToolsRef.current.length) { - next.push('analyzing tool output…') - } - - turnToolsRef.current = next.slice(-8) - setTurnTrail(turnToolsRef.current) - - if (p?.inline_diff) { - sys(p.inline_diff) - } - - break - } - - case 'clarify.request': { - const p = ev.payload - patchOverlayState({ clarify: { choices: p.choices, question: p.question, requestId: p.request_id } }) + case 'clarify.request': + patchOverlayState({ + clarify: { choices: ev.payload.choices, question: ev.payload.question, requestId: ev.payload.request_id } + }) setStatus('waiting for input…') - break - } + return - case 'approval.request': { - const p = ev.payload - patchOverlayState({ approval: { command: p.command, description: p.description } }) + case 'approval.request': + patchOverlayState({ approval: { command: ev.payload.command, description: ev.payload.description } }) setStatus('approval needed') - break - } + return - case 'sudo.request': { - const p = ev.payload - patchOverlayState({ sudo: { requestId: p.request_id } }) + case 'sudo.request': + patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) setStatus('sudo password needed') - break - } + return - case 'secret.request': { - const p = ev.payload - patchOverlayState({ secret: { envVar: p.env_var, prompt: p.prompt, requestId: p.request_id } }) + case 'secret.request': + patchOverlayState({ + secret: { envVar: ev.payload.env_var, prompt: ev.payload.prompt, requestId: ev.payload.request_id } + }) setStatus('secret input needed') - break - } + return - case 'background.complete': { - const p = ev.payload - patchUiState(state => { - const next = new Set(state.bgTasks) + case 'background.complete': + dropBgTask(ev.payload.task_id) + sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) - next.delete(p.task_id) + return - return { ...state, bgTasks: next } - }) - sys(`[bg ${p.task_id}] ${p.text}`) + case 'btw.complete': + dropBgTask('btw:x') + sys(`[btw] ${ev.payload.text}`) - break - } + return - case 'btw.complete': { - const p = ev.payload - patchUiState(state => { - const next = new Set(state.bgTasks) - - next.delete('btw:x') - - return { ...state, bgTasks: next } - }) - sys(`[btw] ${p.text}`) - - break - } - - case 'subagent.start': { - const p = ev.payload - - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: 'running', - taskCount: p.task_count ?? current.taskCount - })) - - break - } + case 'subagent.start': + turnController.upsertSubagent(ev.payload, () => ({ status: 'running' })) + return case 'subagent.thinking': { - const p = ev.payload - const text = String(p.text ?? '').trim() + const text = String(ev.payload.text ?? '').trim() if (!text) { - break + return } - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: current.status === 'completed' ? current.status : 'running', - taskCount: p.task_count ?? current.taskCount, - thinking: current.thinking.at(-1) === text ? current.thinking : [...current.thinking, text].slice(-6) + turnController.upsertSubagent(ev.payload, c => ({ + status: keepCompletedElseRunning(c.status), + thinking: pushThinking(c.thinking, text) })) - break + return } case 'subagent.tool': { - const p = ev.payload - const line = formatToolCall(p.tool_name ?? 'delegate_task', p.tool_preview ?? p.text ?? '') + const line = formatToolCall( + ev.payload.tool_name ?? 'delegate_task', + ev.payload.tool_preview ?? ev.payload.text ?? '' + ) - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: current.status === 'completed' ? current.status : 'running', - taskCount: p.task_count ?? current.taskCount, - tools: current.tools.at(-1) === line ? current.tools : [...current.tools, line].slice(-8) + turnController.upsertSubagent(ev.payload, c => ({ + status: keepCompletedElseRunning(c.status), + tools: pushTool(c.tools, line) })) - break + return } case 'subagent.progress': { - const p = ev.payload - const text = String(p.text ?? '').trim() + const text = String(ev.payload.text ?? '').trim() if (!text) { - break + return } - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - goal: p.goal || current.goal, - status: current.status === 'completed' ? current.status : 'running', - taskCount: p.task_count ?? current.taskCount, - notes: current.notes.at(-1) === text ? current.notes : [...current.notes, text].slice(-6) + turnController.upsertSubagent(ev.payload, c => ({ + notes: pushNote(c.notes, text), + status: keepCompletedElseRunning(c.status) })) - break + return } - case 'subagent.complete': { - const p = ev.payload - const status = p.status ?? 'completed' - - upsertSubagent(p.task_index, p.task_count ?? 1, p.goal, current => ({ - ...current, - durationSeconds: p.duration_seconds ?? current.durationSeconds, - goal: p.goal || current.goal, - status, - summary: p.summary || p.text || current.summary, - taskCount: p.task_count ?? current.taskCount + case 'subagent.complete': + turnController.upsertSubagent(ev.payload, c => ({ + durationSeconds: ev.payload.duration_seconds ?? c.durationSeconds, + status: ev.payload.status ?? 'completed', + summary: ev.payload.summary || ev.payload.text || c.summary })) - break - } + return - case 'message.delta': { - const p = ev.payload - pruneTransient() - endReasoningPhase() - - if (p?.text && !interruptedRef.current) { - bufRef.current = p.rendered ?? bufRef.current + p.text - scheduleStreaming() - } - - break - } + case 'message.delta': + turnController.recordMessageDelta(ev.payload ?? {}) + return case 'message.complete': { - const p = ev.payload - const finalText = (p?.rendered ?? p?.text ?? bufRef.current).trimStart() - const persisted = persistedToolLabelsRef.current - const streamedReasoning = reasoningRef.current.trim() - const payloadReasoning = String(p?.reasoning ?? '').trim() - const savedReasoning = streamedReasoning || payloadReasoning - const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 - const savedToolTokens = toolTokenAccRef.current - - const savedTools = turnToolsRef.current.filter( - line => isToolTrailResultLine(line) && ![...persisted].some(item => sameToolTrailGroup(item, line)) - ) - - const wasInterrupted = interruptedRef.current + const { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } = + turnController.recordMessageComplete(ev.payload ?? {}) if (!wasInterrupted) { appendMessage({ @@ -658,21 +399,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } } - idle() - clearReasoning() - - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - setActivity([]) - bufRef.current = '' setStatus('ready') - if (p?.usage) { - patchUiState(state => ({ ...state, usage: { ...state.usage, ...p.usage } })) + if (ev.payload?.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...ev.payload!.usage } })) } if (queueEditRef.current !== null) { - break + return } const next = dequeue() @@ -681,27 +415,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: sendQueued(next) } - break + return } - case 'error': { - const p = ev.payload - idle() - clearReasoning() - turnToolsRef.current = [] - persistedToolLabelsRef.current.clear() - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - statusTimerRef.current = null - } - - pushActivity(String(p?.message || 'unknown error'), 'error') - sys(`error: ${p?.message}`) + case 'error': + turnController.recordError() + turnController.pushActivity(String(ev.payload?.message || 'unknown error'), 'error') + sys(`error: ${ev.payload?.message}`) setStatus('ready') - - break - } } } } diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 1a23943c09..c3ddab7a29 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -1,12 +1,11 @@ +import { parseSlashCommand } from '../domain/slash.js' import type { SlashExecResponse } from '../gatewayTypes.js' import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' import type { SlashHandlerContext } from './interfaces.js' -import { createSlashCoreHandler } from './slash/createSlashCoreHandler.js' -import { createSlashOpsHandler } from './slash/createSlashOpsHandler.js' -import { createSlashSessionHandler } from './slash/createSlashSessionHandler.js' -import { isStaleSlash } from './slash/isStaleSlash.js' -import { createSlashShared, parseSlashCommand } from './slash/shared.js' +import { findSlashCommand } from './slash/registry.js' +import { createSlashShared } from './slash/shared.js' +import type { SlashRunCtx } from './slash/types.js' import { getUiState } from './uiStore.js' export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { @@ -14,18 +13,37 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b const { catalog } = ctx.local const { send, sys } = ctx.transcript const shared = createSlashShared({ ...ctx.transcript, gw, slashFlightRef: ctx.slashFlightRef }) - const handleCore = createSlashCoreHandler(ctx) - const handleSession = createSlashSessionHandler(ctx, shared) - const handleOps = createSlashOpsHandler(ctx) const handler = (cmd: string): boolean => { const flight = ++ctx.slashFlightRef.current const ui = getUiState() - const sidAtSend = ui.sid - const parsed = { ...parseSlashCommand(cmd), flight, sid: sidAtSend, ui } + const sid = ui.sid + const parsed = parseSlashCommand(cmd) const argTail = parsed.arg ? ` ${parsed.arg}` : '' - if (handleCore(parsed) || handleSession(parsed) || handleOps(parsed)) { + const stale = () => flight !== ctx.slashFlightRef.current || getUiState().sid !== sid + + const guarded = + (fn: (r: T) => void) => + (r: null | T): void => { + if (!stale() && r) { + fn(r) + } + } + + const guardedErr = (e: unknown) => { + if (!stale()) { + sys(`error: ${rpcErrorMessage(e)}`) + } + } + + const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, shared, sid, stale, ui } + + const found = findSlashCommand(parsed.name) + + if (found) { + found.run(parsed.arg, runCtx, cmd) + return true } @@ -51,9 +69,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } - gw.request('slash.exec', { command: cmd.slice(1), session_id: sidAtSend }) + gw.request('slash.exec', { command: cmd.slice(1), session_id: sid }) .then(r => { - if (isStaleSlash(ctx, flight, sidAtSend)) { + if (stale()) { return } @@ -64,41 +82,33 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b ) }) .catch(() => { - gw.request('command.dispatch', { name: parsed.name, arg: parsed.arg, session_id: sidAtSend }) + gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid }) .then((raw: unknown) => { - if (isStaleSlash(ctx, flight, sidAtSend)) { + if (stale()) { return } const d = asCommandDispatch(raw) if (!d) { - sys('error: invalid response: command.dispatch') - - return + return sys('error: invalid response: command.dispatch') } if (d.type === 'exec' || d.type === 'plugin') { - sys(d.output || '(no output)') - } else if (d.type === 'alias') { - handler(`/${d.target}${argTail}`) - } else if (d.type === 'skill') { + return sys(d.output || '(no output)') + } + + if (d.type === 'alias') { + return handler(`/${d.target}${argTail}`) + } + + if (d.type === 'skill') { sys(`⚡ loading skill: ${d.name}`) - if (typeof d.message === 'string' && d.message.trim()) { - send(d.message) - } else { - sys(`/${parsed.name}: skill payload missing message`) - } + return d.message?.trim() ? send(d.message) : sys(`/${parsed.name}: skill payload missing message`) } }) - .catch((e: unknown) => { - if (isStaleSlash(ctx, flight, sidAtSend)) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) + .catch(guardedErr) }) return true diff --git a/ui-tui/src/app/helpers.ts b/ui-tui/src/app/helpers.ts deleted file mode 100644 index 8496008c7a..0000000000 --- a/ui-tui/src/app/helpers.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { buildToolTrailLine, fmtK, userDisplay } from '../lib/text.js' -import type { DetailsMode, Msg, SessionInfo } from '../types.js' - -const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] - -export const parseDetailsMode = (v: unknown): DetailsMode | null => { - const s = typeof v === 'string' ? v.trim().toLowerCase() : '' - - return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null -} - -export const resolveDetailsMode = (d: any): DetailsMode => - parseDetailsMode(d?.details_mode) ?? - { full: 'expanded' as const, collapsed: 'collapsed' as const, truncated: 'collapsed' as const }[ - String(d?.thinking_mode ?? '') - .trim() - .toLowerCase() - ] ?? - 'collapsed' - -export const nextDetailsMode = (m: DetailsMode): DetailsMode => - DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! - -export const introMsg = (info: SessionInfo): Msg => ({ role: 'system', text: '', kind: 'intro', info }) - -export const shortCwd = (cwd: string, max = 28) => { - const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd - - return p.length <= max ? p : `…${p.slice(-(max - 1))}` -} - -export const imageTokenMeta = ( - info: { height?: number; token_estimate?: number; width?: number } | null | undefined -) => { - const dims = info?.width && info?.height ? `${info.width}x${info.height}` : '' - - const tok = - typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' - - return [dims, tok].filter(Boolean).join(' · ') -} - -export const looksLikeSlashCommand = (text: string) => { - if (!text.startsWith('/')) { - return false - } - - const first = text.split(/\s+/, 1)[0] || '' - - return !first.slice(1).includes('/') -} - -export const toTranscriptMessages = (rows: unknown): Msg[] => { - if (!Array.isArray(rows)) { - return [] - } - - const result: Msg[] = [] - let pendingTools: string[] = [] - - for (const row of rows) { - if (!row || typeof row !== 'object') { - continue - } - - const role = (row as any).role - const text = (row as any).text - - if (role === 'tool') { - const name = (row as any).name ?? 'tool' - const ctx = (row as any).context ?? '' - pendingTools.push(buildToolTrailLine(name, ctx)) - - continue - } - - if (typeof text !== 'string' || !text.trim()) { - continue - } - - if (role === 'assistant') { - const msg: Msg = { role, text } - - if (pendingTools.length) { - msg.tools = pendingTools - pendingTools = [] - } - - result.push(msg) - - continue - } - - if (role === 'user' || role === 'system') { - pendingTools = [] - result.push({ role, text }) - } - } - - return result -} - -export function fmtDuration(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)) - const hours = Math.floor(total / 3600) - const mins = Math.floor((total % 3600) / 60) - const secs = total % 60 - - if (hours > 0) { - return `${hours}h ${mins}m` - } - - if (mins > 0) { - return `${mins}m ${secs}s` - } - - return `${secs}s` -} - -export const stickyPromptFromViewport = ( - messages: readonly Msg[], - offsets: ArrayLike, - top: number, - sticky: boolean -) => { - if (sticky || !messages.length) { - return '' - } - - let lo = 0 - let hi = offsets.length - - while (lo < hi) { - const mid = (lo + hi) >> 1 - - if (offsets[mid]! <= top) { - lo = mid + 1 - } else { - hi = mid - } - } - - const first = Math.max(0, Math.min(messages.length - 1, lo - 1)) - - if (messages[first]?.role === 'user' && (offsets[first] ?? 0) + 1 >= top) { - return '' - } - - for (let i = first - 1; i >= 0; i--) { - if (messages[i]?.role !== 'user') { - continue - } - - if ((offsets[i] ?? 0) + 1 >= top) { - continue - } - - return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() - } - - return '' -} diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index aa7e28d4dd..b34ee54bea 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -36,7 +36,7 @@ export interface CompletionItem { } export interface GatewayRpc { - (method: string, params?: Record): Promise + (method: string, params?: Record): Promise } export interface GatewayServices { @@ -53,10 +53,10 @@ export interface OverlayState { approval: ApprovalReq | null clarify: ClarifyReq | null modelPicker: boolean - pager: PagerState | null + pager: null | PagerState picker: boolean - secret: SecretReq | null - sudo: SudoReq | null + secret: null | SecretReq + sudo: null | SudoReq } export interface PagerState { @@ -65,11 +65,6 @@ export interface PagerState { title?: string } -export interface ToolCompleteRibbon { - label: string - line: string -} - export interface TranscriptRow { index: number key: string @@ -81,8 +76,8 @@ export interface UiState { busy: boolean compact: boolean detailsMode: DetailsMode - info: SessionInfo | null - sid: string | null + info: null | SessionInfo + sid: null | string status: string statusBar: boolean theme: Theme @@ -112,18 +107,18 @@ export interface ComposerActions { pushHistory: (text: string) => void replaceQueue: (index: number, text: string) => void setCompIdx: StateSetter - setHistoryIdx: StateSetter + setHistoryIdx: StateSetter setInput: StateSetter setInputBuf: StateSetter setPasteSnips: StateSetter - setQueueEdit: (index: number | null) => void + setQueueEdit: (index: null | number) => void syncQueue: () => void } export interface ComposerRefs { historyDraftRef: MutableRefObject historyRef: MutableRefObject - queueEditRef: MutableRefObject + queueEditRef: MutableRefObject queueRef: MutableRefObject submitRef: MutableRefObject<(value: string) => void> } @@ -132,11 +127,11 @@ export interface ComposerState { compIdx: number compReplace: number completions: CompletionItem[] - historyIdx: number | null + historyIdx: null | number input: string inputBuf: string[] pasteSnips: PasteSnippet[] - queueEditIdx: number | null + queueEditIdx: null | number queuedDisplay: string[] } @@ -152,72 +147,6 @@ export interface UseComposerStateResult { state: ComposerState } -export interface InterruptTurnOptions { - appendMessage: (msg: Msg) => void - gw: { request: (method: string, params?: Record) => Promise } - sid: string - sys: (text: string) => void -} - -export interface TurnActions { - clearReasoning: () => void - endReasoningPhase: () => void - idle: () => void - interruptTurn: (options: InterruptTurnOptions) => void - pruneTransient: () => void - pulseReasoningStreaming: () => void - pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void - pushTrail: (line: string) => void - scheduleReasoning: () => void - scheduleStreaming: () => void - setActivity: StateSetter - setReasoning: StateSetter - setReasoningTokens: StateSetter - setReasoningActive: StateSetter - setToolTokens: StateSetter - setReasoningStreaming: StateSetter - setStreaming: StateSetter - setSubagents: StateSetter - setTools: StateSetter - setTurnTrail: StateSetter -} - -export interface TurnRefs { - activeToolsRef: MutableRefObject - bufRef: MutableRefObject - interruptedRef: MutableRefObject - lastStatusNoteRef: MutableRefObject - persistedToolLabelsRef: MutableRefObject> - protocolWarnedRef: MutableRefObject - reasoningRef: MutableRefObject - reasoningStreamingTimerRef: MutableRefObject | null> - reasoningTimerRef: MutableRefObject | null> - statusTimerRef: MutableRefObject | null> - streamTimerRef: MutableRefObject | null> - toolTokenAccRef: MutableRefObject - toolCompleteRibbonRef: MutableRefObject - turnToolsRef: MutableRefObject -} - -export interface TurnState { - activity: ActivityItem[] - reasoning: string - reasoningTokens: number - reasoningActive: boolean - reasoningStreaming: boolean - streaming: string - subagents: SubagentProgress[] - toolTokens: number - tools: ActiveTool[] - turnTrail: string[] -} - -export interface UseTurnStateResult { - actions: TurnActions - refs: TurnRefs - state: TurnState -} - export interface InputHandlerActions { answerClarify: (answer: string) => void appendMessage: (msg: Msg) => void @@ -238,15 +167,11 @@ export interface InputHandlerContext { gateway: GatewayServices terminal: { hasSelection: boolean - scrollRef: RefObject + scrollRef: RefObject scrollWithSelection: (delta: number) => void selection: SelectionApi stdout?: NodeJS.WriteStream } - turn: { - actions: TurnActions - refs: TurnRefs - } voice: { recording: boolean setProcessing: StateSetter @@ -262,7 +187,7 @@ export interface InputHandlerResult { export interface GatewayEventHandlerContext { composer: { dequeue: () => string | undefined - queueEditRef: MutableRefObject + queueEditRef: MutableRefObject sendQueued: (text: string) => void } gateway: GatewayServices @@ -271,7 +196,7 @@ export interface GatewayEventHandlerContext { colsRef: MutableRefObject newSession: (msg?: string) => void resetSession: () => void - setCatalog: StateSetter + setCatalog: StateSetter } system: { bellOnComplete: boolean @@ -282,45 +207,9 @@ export interface GatewayEventHandlerContext { appendMessage: (msg: Msg) => void setHistoryItems: StateSetter } - turn: { - actions: Pick< - TurnActions, - | 'clearReasoning' - | 'endReasoningPhase' - | 'idle' - | 'pruneTransient' - | 'pulseReasoningStreaming' - | 'pushActivity' - | 'pushTrail' - | 'scheduleReasoning' - | 'scheduleStreaming' - | 'setActivity' - | 'setReasoningTokens' - | 'setStreaming' - | 'setSubagents' - | 'setToolTokens' - | 'setTools' - | 'setTurnTrail' - > - refs: Pick< - TurnRefs, - | 'activeToolsRef' - | 'bufRef' - | 'interruptedRef' - | 'lastStatusNoteRef' - | 'persistedToolLabelsRef' - | 'protocolWarnedRef' - | 'reasoningRef' - | 'statusTimerRef' - | 'toolTokenAccRef' - | 'toolCompleteRibbonRef' - | 'turnToolsRef' - > - } } export interface SlashHandlerContext { - slashFlightRef: MutableRefObject composer: { enqueue: (text: string) => void hasSelection: boolean @@ -331,20 +220,21 @@ export interface SlashHandlerContext { } gateway: GatewayServices local: { - catalog: SlashCatalog | null + catalog: null | SlashCatalog getHistoryItems: () => Msg[] getLastUserMsg: () => string - maybeWarn: (value: any) => void + maybeWarn: (value: unknown) => void } session: { - closeSession: (targetSid?: string | null) => Promise + closeSession: (targetSid?: null | string) => Promise die: () => void guardBusySessionSwitch: (what?: string) => boolean newSession: (msg?: string) => void - resetVisibleHistory: (info?: SessionInfo | null) => void + resetVisibleHistory: (info?: null | SessionInfo) => void resumeById: (id: string) => void setSessionStartedAt: StateSetter } + slashFlightRef: MutableRefObject transcript: { page: (text: string, title?: string) => void panel: (title: string, sections: PanelSection[]) => void @@ -377,7 +267,7 @@ export interface AppLayoutComposerProps { input: string inputBuf: string[] pagerPageSize: number - queueEditIdx: number | null + queueEditIdx: null | number queuedDisplay: string[] submit: (value: string) => void updateInput: StateSetter @@ -386,9 +276,9 @@ export interface AppLayoutComposerProps { export interface AppLayoutProgressProps { activity: ActivityItem[] reasoning: string - reasoningTokens: number reasoningActive: boolean reasoningStreaming: boolean + reasoningTokens: number showProgressArea: boolean showStreamingArea: boolean streaming: string @@ -401,7 +291,7 @@ export interface AppLayoutProgressProps { export interface AppLayoutStatusProps { cwdLabel: string goodVibesTick: number - sessionStartedAt: number | null + sessionStartedAt: null | number showStickyPrompt: boolean statusColor: string stickyPrompt: string @@ -410,7 +300,7 @@ export interface AppLayoutStatusProps { export interface AppLayoutTranscriptProps { historyItems: Msg[] - scrollRef: RefObject + scrollRef: RefObject virtualHistory: VirtualHistoryState virtualRows: TranscriptRow[] } diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts new file mode 100644 index 0000000000..4b619bed5a --- /dev/null +++ b/ui-tui/src/app/slash/commands/core.ts @@ -0,0 +1,293 @@ +import { dailyFortune, randomFortune } from '../../../content/fortunes.js' +import { HOTKEYS } from '../../../content/hotkeys.js' +import { nextDetailsMode, parseDetailsMode } from '../../../domain/details.js' +import type { ConfigGetValueResponse, ConfigSetResponse, SessionUndoResponse } from '../../../gatewayTypes.js' +import { writeOsc52Clipboard } from '../../../lib/osc52.js' +import type { DetailsMode, Msg, PanelSection } from '../../../types.js' +import { patchOverlayState } from '../../overlayStore.js' +import { patchUiState } from '../../uiStore.js' +import type { SlashCommand } from '../types.js' + +const flagFromArg = (arg: string, current: boolean): boolean | null => { + const mode = arg.trim().toLowerCase() + + if (!arg) { + return !current + } + + if (mode === 'on') { + return true + } + + if (mode === 'off') { + return false + } + + if (mode === 'toggle') { + return !current + } + + return null +} + +const DETAIL_MODES = new Set(['collapsed', 'cycle', 'expanded', 'hidden', 'toggle']) + +export const coreCommands: SlashCommand[] = [ + { + help: 'list commands + hotkeys', + name: 'help', + run: (_arg, ctx) => { + const sections: PanelSection[] = (ctx.local.catalog?.categories ?? []).map(cat => ({ + rows: cat.pairs, + title: cat.name + })) + + if (ctx.local.catalog?.skillCount) { + sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` }) + } + + sections.push({ + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ], + title: 'TUI' + }) + sections.push({ rows: HOTKEYS, title: 'Hotkeys' }) + + ctx.transcript.panel('Commands', sections) + } + }, + + { + aliases: ['exit', 'q'], + help: 'exit hermes', + name: 'quit', + run: (_arg, ctx) => ctx.session.die() + }, + + { + aliases: ['new'], + help: 'start a new session', + name: 'clear', + run: (_arg, ctx, cmd) => { + if (ctx.session.guardBusySessionSwitch('switch sessions')) { + return + } + + patchUiState({ status: 'forging session…' }) + ctx.session.newSession(cmd.startsWith('/new') ? 'new session started' : undefined) + } + }, + + { + help: 'resume a prior session', + name: 'resume', + run: (arg, ctx) => { + if (ctx.session.guardBusySessionSwitch('switch sessions')) { + return + } + + arg ? ctx.session.resumeById(arg) : patchOverlayState({ picker: true }) + } + }, + + { + help: 'toggle compact transcript', + name: 'compact', + run: (arg, ctx) => { + const next = flagFromArg(arg, ctx.ui.compact) + + if (next === null) { + return ctx.transcript.sys('usage: /compact [on|off|toggle]') + } + + patchUiState({ compact: next }) + ctx.gateway.rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`compact ${next ? 'on' : 'off'}`)) + } + }, + + { + aliases: ['detail'], + help: 'control agent detail visibility', + name: 'details', + run: (arg, ctx) => { + const { gateway, transcript, ui } = ctx + + if (!arg) { + gateway + .rpc('config.get', { key: 'details_mode' }) + .then(r => { + if (ctx.stale()) { + return + } + + const mode = parseDetailsMode(r?.value) ?? ui.detailsMode + + patchUiState({ detailsMode: mode }) + transcript.sys(`details: ${mode}`) + }) + .catch(() => { + if (!ctx.stale()) { + transcript.sys(`details: ${ui.detailsMode}`) + } + }) + + return + } + + const mode = arg.trim().toLowerCase() + + if (!DETAIL_MODES.has(mode)) { + return transcript.sys('usage: /details [hidden|collapsed|expanded|cycle]') + } + + const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) + + patchUiState({ detailsMode: next }) + gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) + transcript.sys(`details: ${next}`) + } + }, + + { + help: 'local fortune', + name: 'fortune', + run: (arg, ctx) => { + const key = arg.trim().toLowerCase() + + if (!arg || key === 'random') { + return ctx.transcript.sys(randomFortune()) + } + + if (['daily', 'stable', 'today'].includes(key)) { + return ctx.transcript.sys(dailyFortune(ctx.sid)) + } + + ctx.transcript.sys('usage: /fortune [random|daily]') + } + }, + + { + help: 'copy selection or assistant message', + name: 'copy', + run: (arg, ctx) => { + const { sys } = ctx.transcript + + if (!arg && ctx.composer.hasSelection && ctx.composer.selection.copySelection()) { + return sys('copied selection') + } + + if (arg && Number.isNaN(parseInt(arg, 10))) { + return sys('usage: /copy [number]') + } + + const all = ctx.local.getHistoryItems().filter(m => m.role === 'assistant') + const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] + + if (!target) { + return sys('nothing to copy') + } + + writeOsc52Clipboard(target.text) + sys('sent OSC52 copy sequence (terminal support required)') + } + }, + + { + help: 'paste clipboard image', + name: 'paste', + run: (arg, ctx) => (arg ? ctx.transcript.sys('usage: /paste') : ctx.composer.paste()) + }, + + { + help: 'view gateway logs', + name: 'logs', + run: (arg, ctx) => { + const text = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) + + text ? ctx.transcript.page(text, 'Logs') : ctx.transcript.sys('no gateway logs') + } + }, + + { + aliases: ['sb'], + help: 'toggle status bar', + name: 'statusbar', + run: (arg, ctx) => { + const next = flagFromArg(arg, ctx.ui.statusBar) + + if (next === null) { + return ctx.transcript.sys('usage: /statusbar [on|off|toggle]') + } + + patchUiState({ statusBar: next }) + ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) + + queueMicrotask(() => ctx.transcript.sys(`status bar ${next ? 'on' : 'off'}`)) + } + }, + + { + help: 'inspect or enqueue a message', + name: 'queue', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys(`${ctx.composer.queueRef.current.length} queued message(s)`) + } + + ctx.composer.enqueue(arg) + ctx.transcript.sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) + } + }, + + { + help: 'undo last exchange', + name: 'undo', + run: (_arg, ctx) => { + if (!ctx.sid) { + return ctx.transcript.sys('nothing to undo') + } + + ctx.gateway.rpc('session.undo', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if ((r.removed ?? 0) > 0) { + ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev)) + ctx.transcript.sys(`undid ${r.removed} messages`) + } else { + ctx.transcript.sys('nothing to undo') + } + }) + ) + } + }, + + { + help: 'retry last user message', + name: 'retry', + run: (_arg, ctx) => { + const last = ctx.local.getLastUserMsg() + + if (!last) { + return ctx.transcript.sys('nothing to retry') + } + + if (!ctx.sid) { + return ctx.transcript.send(last) + } + + ctx.gateway.rpc('session.undo', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if ((r.removed ?? 0) <= 0) { + return ctx.transcript.sys('nothing to retry') + } + + ctx.transcript.setHistoryItems((prev: Msg[]) => ctx.transcript.trimLastExchange(prev)) + ctx.transcript.send(last) + }) + ) + } + } +] diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts new file mode 100644 index 0000000000..3ea300ebed --- /dev/null +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -0,0 +1,368 @@ +import type { + AgentsListResponse, + BrowserManageResponse, + ConfigShowResponse, + CronListResponse, + PluginsListResponse, + RollbackActionResponse, + RollbackListResponse, + SkillsBrowseResponse, + SkillsListResponse, + SlashExecResponse, + ToolsConfigureResponse, + ToolsetsListResponse, + ToolsListResponse, + ToolsShowResponse +} from '../../../gatewayTypes.js' +import type { PanelSection } from '../../../types.js' +import type { SlashCommand, SlashRunCtx } from '../types.js' + +const passthroughSlash = (ctx: SlashRunCtx, cmd: string, fallback: string) => + ctx.gateway.gw + .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + ctx.transcript.sys(r?.warning ? `warning: ${r.warning}\n${r?.output || fallback}` : r?.output || fallback) + }) + .catch(ctx.guardedErr) + +const clip = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}…` : s) + +export const opsCommands: SlashCommand[] = [ + { + help: 'list or restore checkpoints', + name: 'rollback', + run: (arg, ctx) => { + const [sub, ...rest] = (arg || 'list').split(/\s+/) + + if (!sub || sub === 'list') { + return ctx.gateway.rpc('rollback.list', { session_id: ctx.sid }).then( + ctx.guarded(r => { + if (!r.checkpoints?.length) { + return ctx.transcript.sys('no checkpoints') + } + + ctx.transcript.panel('Checkpoints', [ + { + rows: r.checkpoints.map( + (c, i) => [`${i + 1} ${c.hash?.slice(0, 8) ?? ''}`, c.message ?? ''] as [string, string] + ) + } + ]) + }) + ) + } + + const isRestoreOrDiff = sub === 'restore' || sub === 'diff' + const hash = isRestoreOrDiff ? rest[0] : sub + const filePath = (isRestoreOrDiff ? rest.slice(1) : rest).join(' ').trim() + const method = sub === 'diff' ? 'rollback.diff' : 'rollback.restore' + + ctx.gateway + .rpc(method, { + hash, + session_id: ctx.sid, + ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) + }) + .then(ctx.guarded(r => ctx.transcript.sys(r.rendered || r.diff || r.message || 'done'))) + } + }, + + { + help: 'manage browser connection', + name: 'browser', + run: (arg, ctx) => { + const [action, url] = (arg || 'status').split(/\s+/) + + ctx.gateway + .rpc('browser.manage', { action, ...(url ? { url } : {}) }) + .then( + ctx.guarded(r => + ctx.transcript.sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') + ) + ) + } + }, + + { + help: 'list installed plugins', + name: 'plugins', + run: (_arg, ctx) => { + ctx.gateway.rpc('plugins.list', {}).then( + ctx.guarded(r => { + if (!r.plugins?.length) { + return ctx.transcript.sys('no plugins') + } + + ctx.transcript.panel('Plugins', [ + { items: r.plugins.map(p => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) } + ]) + }) + ) + } + }, + + { + help: 'list or browse skills', + name: 'skills', + run: (arg, ctx, cmd) => { + const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean) + + if (!sub || sub === 'list') { + return ctx.gateway.rpc('skills.manage', { action: 'list' }).then( + ctx.guarded(r => { + if (!r.skills || !Object.keys(r.skills).length) { + return ctx.transcript.sys('no skills installed') + } + + ctx.transcript.panel( + 'Installed Skills', + Object.entries(r.skills).map(([title, items]) => ({ items, title })) + ) + }) + ) + } + + if (sub === 'browse') { + const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 + + return ctx.gateway.rpc('skills.manage', { action: 'browse', page: pageNumber }).then( + ctx.guarded(r => { + if (!r.items?.length) { + return ctx.transcript.sys('no skills found in the hub') + } + + const page = r.page ?? 1 + const totalPages = r.total_pages ?? 1 + + const sections: PanelSection[] = [ + { + rows: r.items.map(s => [s.name ?? '', clip(s.description ?? '', 60)] as [string, string]) + } + ] + + if (page < totalPages) { + sections.push({ text: `/skills browse ${page + 1} → next page` }) + } + + if (page > 1) { + sections.push({ text: `/skills browse ${page - 1} → prev page` }) + } + + ctx.transcript.panel(`Skills Hub (page ${page}/${totalPages}, ${r.total ?? 0} total)`, sections) + }) + ) + } + + passthroughSlash(ctx, cmd, '/skills: no output') + } + }, + + { + aliases: ['tasks'], + help: 'running agents', + name: 'agents', + run: (_arg, ctx) => { + ctx.gateway + .rpc('agents.list', {}) + .then( + ctx.guarded(r => { + const processes = r.processes ?? [] + const running = processes.filter(p => p.status === 'running') + const finished = processes.filter(p => p.status !== 'running') + const sections: PanelSection[] = [] + + if (running.length) { + sections.push({ + rows: running.map(p => [p.session_id.slice(0, 8), p.command ?? '']), + title: `Running (${running.length})` + }) + } + + if (finished.length) { + sections.push({ + rows: finished.map(p => [p.session_id.slice(0, 8), p.command ?? '']), + title: `Finished (${finished.length})` + }) + } + + if (!sections.length) { + sections.push({ text: 'No active processes' }) + } + + ctx.transcript.panel('Agents', sections) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'list or manage cron jobs', + name: 'cron', + run: (arg, ctx, cmd) => { + if (arg && arg !== 'list') { + return passthroughSlash(ctx, cmd, '(no output)') + } + + ctx.gateway + .rpc('cron.manage', { action: 'list' }) + .then( + ctx.guarded(r => { + const jobs = r.jobs ?? [] + + if (!jobs.length) { + return ctx.transcript.sys('no scheduled jobs') + } + + ctx.transcript.panel('Cron', [ + { + rows: jobs.map( + j => + [j.name || j.job_id?.slice(0, 12) || '', `${j.schedule ?? ''} · ${j.state ?? 'active'}`] as [ + string, + string + ] + ) + } + ]) + }) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'show configuration', + name: 'config', + run: (_arg, ctx) => { + ctx.gateway + .rpc('config.show', {}) + .then( + ctx.guarded(r => + ctx.transcript.panel( + 'Config', + (r.sections ?? []).map(s => ({ rows: s.rows, title: s.title })) + ) + ) + ) + .catch(ctx.guardedErr) + } + }, + + { + help: 'list, enable, disable tools', + name: 'tools', + run: (arg, ctx) => { + const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) + + if (!subcommand) { + return ctx.gateway + .rpc('tools.show', { session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + if (!r?.sections?.length) { + return ctx.transcript.sys('no tools') + } + + ctx.transcript.panel( + `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, + r.sections.map(section => ({ + rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]), + title: section.name + })) + ) + }) + .catch(ctx.guardedErr) + } + + if (subcommand === 'list') { + return ctx.gateway + .rpc('tools.list', { session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + if (!r?.toolsets?.length) { + return ctx.transcript.sys('no tools') + } + + ctx.transcript.panel( + 'Tools', + r.toolsets.map(ts => ({ + items: ts.tools, + title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]` + })) + ) + }) + .catch(ctx.guardedErr) + } + + if (subcommand === 'disable' || subcommand === 'enable') { + if (!names.length) { + ctx.transcript.sys(`usage: /tools ${subcommand} [name ...]`) + ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`) + ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`) + + return + } + + return ctx.gateway + .rpc('tools.configure', { action: subcommand, names, session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (r.info) { + ctx.session.setSessionStartedAt(Date.now()) + ctx.session.resetVisibleHistory(r.info) + } + + r.changed?.length && + ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) + r.unknown?.length && ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`) + r.missing_servers?.length && ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) + r.reset && ctx.transcript.sys('session reset. new tool configuration is active.') + }) + ) + .catch(ctx.guardedErr) + } + + ctx.transcript.sys('usage: /tools [list|disable|enable] ...') + } + }, + + { + help: 'list toolsets', + name: 'toolsets', + run: (_arg, ctx) => { + ctx.gateway + .rpc('toolsets.list', { session_id: ctx.sid }) + .then( + ctx.guarded(r => { + if (!r.toolsets?.length) { + return ctx.transcript.sys('no toolsets') + } + + ctx.transcript.panel('Toolsets', [ + { + rows: r.toolsets.map( + ts => + [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ + string, + string + ] + ) + } + ]) + }) + ) + .catch(ctx.guardedErr) + } + } +] diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts new file mode 100644 index 0000000000..c8dfa587a9 --- /dev/null +++ b/ui-tui/src/app/slash/commands/session.ts @@ -0,0 +1,462 @@ +import { imageTokenMeta, introMsg, toTranscriptMessages } from '../../../domain/messages.js' +import type { + BackgroundStartResponse, + BtwStartResponse, + ConfigGetValueResponse, + ConfigSetResponse, + ImageAttachResponse, + InsightsResponse, + ReloadMcpResponse, + SessionBranchResponse, + SessionCompressResponse, + SessionHistoryResponse, + SessionSaveResponse, + SessionTitleResponse, + SessionUsageResponse, + SlashExecResponse, + VoiceToggleResponse +} from '../../../gatewayTypes.js' +import { fmtK } from '../../../lib/text.js' +import type { PanelSection } from '../../../types.js' +import { patchOverlayState } from '../../overlayStore.js' +import { patchUiState } from '../../uiStore.js' +import type { SlashCommand } from '../types.js' + +const PAGE_TITLES: Record = { + debug: 'Debug', + fast: 'Fast', + platforms: 'Platforms', + snapshot: 'Snapshot' +} + +const passthrough = (name: string): SlashCommand => ({ + name, + run: (_arg, ctx, cmd) => + ctx.shared.showSlashOutput({ + command: cmd.slice(1), + flight: ctx.flight, + sid: ctx.sid, + title: PAGE_TITLES[name] ?? name + }) +}) + +const historyLabel = (role: string) => (role === 'assistant' ? 'Hermes' : role === 'user' ? 'You' : 'System') + +export const sessionCommands: SlashCommand[] = [ + passthrough('debug'), + passthrough('fast'), + passthrough('platforms'), + passthrough('snapshot'), + + { + aliases: ['bg'], + help: 'launch a background prompt', + name: 'background', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys('/background ') + } + + ctx.gateway.rpc('prompt.background', { session_id: ctx.sid, text: arg }).then( + ctx.guarded(r => { + if (!r.task_id) { + return + } + + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(r.task_id!) })) + ctx.transcript.sys(`bg ${r.task_id} started`) + }) + ) + } + }, + + { + help: 'by-the-way follow-up', + name: 'btw', + run: (arg, ctx) => { + if (!arg) { + return ctx.transcript.sys('/btw ') + } + + ctx.gateway.rpc('prompt.btw', { session_id: ctx.sid, text: arg }).then( + ctx.guarded(() => { + patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) + ctx.transcript.sys('btw running…') + }) + ) + } + }, + + { + help: 'change or show model', + name: 'model', + run: (arg, ctx) => { + if (ctx.session.guardBusySessionSwitch('change models')) { + return + } + + if (!arg) { + return patchOverlayState({ modelPicker: true }) + } + + ctx.gateway.rpc('config.set', { key: 'model', session_id: ctx.sid, value: arg.trim() }).then( + ctx.guarded(r => { + if (!r.value) { + return ctx.transcript.sys('error: invalid response: model switch') + } + + ctx.transcript.sys(`model → ${r.value}`) + ctx.local.maybeWarn(r) + + patchUiState(state => ({ + ...state, + info: state.info ? { ...state.info, model: r.value! } : { model: r.value!, skills: {}, tools: {} } + })) + }) + ) + } + }, + + { + help: 'attach an image', + name: 'image', + run: (arg, ctx) => { + ctx.gateway.rpc('image.attach', { path: arg, session_id: ctx.sid }).then( + ctx.guarded(r => { + const meta = imageTokenMeta(r) + + ctx.transcript.sys(`attached image: ${r.name ?? ''}${meta ? ` · ${meta}` : ''}`) + r.remainder && ctx.composer.setInput(r.remainder) + }) + ) + } + }, + + { + help: 'show provider details', + name: 'provider', + run: (_arg, ctx) => { + ctx.gateway.gw + .request('slash.exec', { command: 'provider', session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + ctx.transcript.page( + r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', + 'Provider' + ) + }) + .catch(ctx.guardedErr) + } + }, + + { + help: 'switch theme skin', + name: 'skin', + run: (arg, ctx) => { + if (arg) { + return ctx.gateway + .rpc('config.set', { key: 'skin', value: arg }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`skin → ${r.value}`))) + } + + ctx.gateway + .rpc('config.get', { key: 'skin' }) + .then(ctx.guarded(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`))) + } + }, + + { + help: 'toggle yolo mode', + name: 'yolo', + run: (_arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'yolo', session_id: ctx.sid }) + .then(ctx.guarded(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`))) + } + }, + + { + help: 'inspect or set reasoning mode', + name: 'reasoning', + run: (arg, ctx) => { + if (!arg) { + return ctx.gateway + .rpc('config.get', { key: 'reasoning' }) + .then( + ctx.guarded( + r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + ) + ) + } + + ctx.gateway + .rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`))) + } + }, + + { + help: 'cycle verbose output', + name: 'verbose', + run: (arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`))) + } + }, + + { + help: 'personality panel or switch', + name: 'personality', + run: (arg, ctx) => { + if (arg) { + return ctx.gateway + .rpc('config.set', { key: 'personality', session_id: ctx.sid, value: arg }) + .then( + ctx.guarded(r => { + r.history_reset && ctx.session.resetVisibleHistory(r.info ?? null) + ctx.transcript.sys( + `personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}` + ) + ctx.local.maybeWarn(r) + }) + ) + } + + ctx.gateway.gw + .request('slash.exec', { command: 'personality', session_id: ctx.sid }) + .then(r => { + if (ctx.stale()) { + return + } + + ctx.transcript.panel('Personality', [ + { + text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)' + } + ]) + }) + .catch(ctx.guardedErr) + } + }, + + { + help: 'compress transcript', + name: 'compress', + run: (arg, ctx) => { + ctx.gateway + .rpc('session.compress', { + session_id: ctx.sid, + ...(arg ? { focus_topic: arg } : {}) + }) + .then( + ctx.guarded(r => { + if (Array.isArray(r.messages)) { + const rows = toTranscriptMessages(r.messages) + + ctx.transcript.setHistoryItems(r.info ? [introMsg(r.info), ...rows] : rows) + } + + r.info && patchUiState({ info: r.info }) + r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) + + if ((r.removed ?? 0) <= 0) { + return ctx.transcript.sys('nothing to compress') + } + + ctx.transcript.sys( + `compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}` + ) + }) + ) + } + }, + + { + help: 'stop background processes', + name: 'stop', + run: (_arg, ctx) => { + ctx.gateway + .rpc<{ killed?: number }>('process.stop', {}) + .then( + ctx.guarded<{ killed?: number }>(r => ctx.transcript.sys(`killed ${r.killed ?? 0} registered process(es)`)) + ) + } + }, + + { + aliases: ['fork'], + help: 'branch the session', + name: 'branch', + run: (arg, ctx) => { + const prevSid = ctx.sid + + ctx.gateway.rpc('session.branch', { name: arg, session_id: ctx.sid }).then( + ctx.guarded(r => { + if (!r.session_id) { + return + } + + void ctx.session.closeSession(prevSid) + patchUiState({ sid: r.session_id }) + ctx.session.setSessionStartedAt(Date.now()) + ctx.transcript.setHistoryItems([]) + ctx.transcript.sys(`branched → ${r.title ?? ''}`) + }) + ) + } + }, + + { + aliases: ['reload_mcp'], + help: 'reload MCP servers', + name: 'reload-mcp', + run: (_arg, ctx) => + ctx.gateway + .rpc('reload.mcp', { session_id: ctx.sid }) + .then(ctx.guarded(() => ctx.transcript.sys('MCP reloaded'))) + }, + + { + help: 'inspect or set session title', + name: 'title', + run: (arg, ctx) => { + ctx.gateway + .rpc('session.title', { session_id: ctx.sid, ...(arg ? { title: arg } : {}) }) + .then(ctx.guarded(r => ctx.transcript.sys(`title: ${r.title || '(none)'}`))) + } + }, + + { + help: 'session usage', + name: 'usage', + run: (_arg, ctx) => { + ctx.gateway.rpc('session.usage', { session_id: ctx.sid }).then(r => { + if (ctx.stale()) { + return + } + + if (r) { + patchUiState({ + usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } + }) + } + + if (!r?.calls) { + return ctx.transcript.sys('no API calls yet') + } + + const f = (v: number | undefined) => (v ?? 0).toLocaleString() + const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + const sections: PanelSection[] = [{ rows }] + + cost && rows.push(['Cost', cost]) + r.context_max && + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + r.compressions && sections.push({ text: `Compressions: ${r.compressions}` }) + + ctx.transcript.panel('Usage', sections) + }) + } + }, + + { + help: 'save transcript to disk', + name: 'save', + run: (_arg, ctx) => { + ctx.gateway + .rpc('session.save', { session_id: ctx.sid }) + .then(ctx.guarded(r => r.file && ctx.transcript.sys(`saved: ${r.file}`))) + } + }, + + { + help: 'view message history', + name: 'history', + run: (_arg, ctx) => { + ctx.gateway.rpc('session.history', { session_id: ctx.sid }).then(r => { + if (ctx.stale() || typeof r?.count !== 'number') { + return + } + + if (!r.messages?.length) { + return ctx.transcript.sys(`${r.count} messages`) + } + + const body = r.messages + .map((m, i) => + m.role === 'tool' + ? `[Tool #${i + 1}] ${m.name || 'tool'} ${m.context || ''}`.trim() + : `[${historyLabel(m.role)} #${i + 1}] ${m.text || ''}`.trim() + ) + .join('\n\n') + + ctx.transcript.page(body, `History (${r.count})`) + }) + } + }, + + { + help: 'show current profile', + name: 'profile', + run: (_arg, ctx) => { + ctx.gateway.rpc('config.get', { key: 'profile' }).then( + ctx.guarded(r => { + const text = r.display || r.home || '(unknown profile)' + const lines = text.split('\n').filter(Boolean) + + lines.length <= 2 ? ctx.transcript.panel('Profile', [{ text }]) : ctx.transcript.page(text, 'Profile') + }) + ) + } + }, + + { + help: 'toggle voice input', + name: 'voice', + run: (arg, ctx) => { + const action = arg === 'on' || arg === 'off' ? arg : 'status' + + ctx.gateway.rpc('voice.toggle', { action }).then( + ctx.guarded(r => { + ctx.voice.setVoiceEnabled(!!r.enabled) + ctx.transcript.sys(`voice: ${r.enabled ? 'on' : 'off'}`) + }) + ) + } + }, + + { + help: 'view usage insights', + name: 'insights', + run: (arg, ctx) => { + ctx.gateway.rpc('insights.get', { days: parseInt(arg) || 30 }).then( + ctx.guarded(r => + ctx.transcript.panel('Insights', [ + { + rows: [ + ['Period', `${r.days ?? 0} days`], + ['Sessions', `${r.sessions ?? 0}`], + ['Messages', `${r.messages ?? 0}`] + ] + } + ]) + ) + ) + } + } +] diff --git a/ui-tui/src/app/slash/createSlashCoreHandler.ts b/ui-tui/src/app/slash/createSlashCoreHandler.ts deleted file mode 100644 index 7cb893c4c9..0000000000 --- a/ui-tui/src/app/slash/createSlashCoreHandler.ts +++ /dev/null @@ -1,341 +0,0 @@ -import { HOTKEYS } from '../../constants.js' -import { writeOsc52Clipboard } from '../../lib/osc52.js' -import type { DetailsMode, PanelSection } from '../../types.js' -import { nextDetailsMode, parseDetailsMode } from '../helpers.js' -import type { SlashHandlerContext } from '../interfaces.js' -import { patchOverlayState } from '../overlayStore.js' -import { patchUiState } from '../uiStore.js' - -import { isStaleSlash } from './isStaleSlash.js' - -const FORTUNES = [ - 'you are one clean refactor away from clarity', - 'a tiny rename today prevents a huge bug tomorrow', - 'your next commit message will be immaculate', - 'the edge case you are ignoring is already solved in your head', - 'minimal diff, maximal calm', - 'today favors bold deletions over new abstractions', - 'the right helper is already in your codebase', - 'you will ship before overthinking catches up', - 'tests are about to save your future self', - 'your instincts are correctly suspicious of that one branch' -] - -const LEGENDARY_FORTUNES = [ - 'legendary drop: one-line fix, first try', - 'legendary drop: every flaky test passes cleanly', - 'legendary drop: your diff teaches by itself' -] - -const hash = (input: string) => { - let out = 2166136261 - - for (let i = 0; i < input.length; i++) { - out ^= input.charCodeAt(i) - out = Math.imul(out, 16777619) - } - - return out >>> 0 -} - -const fortuneFromScore = (score: number) => { - const rare = score % 20 === 0 - const bag = rare ? LEGENDARY_FORTUNES : FORTUNES - - return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` -} - -const randomFortune = () => fortuneFromScore(Math.floor(Math.random() * 0x7fffffff)) - -const dailyFortune = (sid: null | string) => fortuneFromScore(hash(`${sid || 'anon'}|${new Date().toDateString()}`)) - -export function createSlashCoreHandler(ctx: SlashHandlerContext) { - const { enqueue, hasSelection, paste, queueRef, selection } = ctx.composer - const { catalog, getHistoryItems, getLastUserMsg } = ctx.local - const { guardBusySessionSwitch, newSession, resumeById } = ctx.session - const { panel, send, setHistoryItems, sys, trimLastExchange } = ctx.transcript - - return ({ arg, flight, name, sid, ui }: SlashCommand) => { - switch (name) { - case 'help': { - const sections: PanelSection[] = (catalog?.categories ?? []).map(({ name: catName, pairs }: any) => ({ - title: catName, - rows: pairs - })) - - if (catalog?.skillCount) { - sections.push({ text: `${catalog.skillCount} skill commands available — /skills to browse` }) - } - - sections.push({ - title: 'TUI', - rows: [ - ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], - ['/fortune [random|daily]', 'show a random or daily local fortune'] - ] - }) - sections.push({ title: 'Hotkeys', rows: HOTKEYS }) - panel('Commands', sections) - - return true - } - - case 'quit': - - case 'exit': - - case 'q': - ctx.session.die() - - return true - - case 'clear': - - case 'new': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - patchUiState({ status: 'forging session…' }) - newSession(name === 'new' ? 'new session started' : undefined) - - return true - - case 'resume': - if (guardBusySessionSwitch('switch sessions')) { - return true - } - - arg ? resumeById(arg) : patchOverlayState({ picker: true }) - - return true - case 'compact': { - const mode = arg.trim().toLowerCase() - - if (arg && !['on', 'off', 'toggle'].includes(mode)) { - sys('usage: /compact [on|off|toggle]') - - return true - } - - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.compact - - patchUiState({ compact: next }) - ctx.gateway.rpc('config.set', { key: 'compact', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`compact ${next ? 'on' : 'off'}`)) - - return true - } - - case 'details': - - case 'detail': - if (!arg) { - ctx.gateway - .rpc('config.get', { key: 'details_mode' }) - .then((r: any) => { - if (isStaleSlash(ctx, flight, sid)) { - return - } - - const mode = parseDetailsMode(r?.value) ?? ui.detailsMode - - patchUiState({ detailsMode: mode }) - sys(`details: ${mode}`) - }) - .catch(() => { - if (isStaleSlash(ctx, flight, sid)) { - return - } - - sys(`details: ${ui.detailsMode}`) - }) - - return true - } - - { - const mode = arg.trim().toLowerCase() - - if (!['hidden', 'collapsed', 'expanded', 'cycle', 'toggle'].includes(mode)) { - sys('usage: /details [hidden|collapsed|expanded|cycle]') - - return true - } - - const next = mode === 'cycle' || mode === 'toggle' ? nextDetailsMode(ui.detailsMode) : (mode as DetailsMode) - - patchUiState({ detailsMode: next }) - ctx.gateway.rpc('config.set', { key: 'details_mode', value: next }).catch(() => {}) - sys(`details: ${next}`) - } - - return true - - case 'fortune': - if (!arg || arg.trim().toLowerCase() === 'random') { - sys(randomFortune()) - - return true - } - - if (['daily', 'today', 'stable'].includes(arg.trim().toLowerCase())) { - sys(dailyFortune(sid)) - - return true - } - - sys('usage: /fortune [random|daily]') - - return true - case 'copy': { - if (!arg && hasSelection) { - const copied = selection.copySelection() - - if (copied) { - sys('copied selection') - - return true - } - } - - if (arg && Number.isNaN(parseInt(arg, 10))) { - sys('usage: /copy [number]') - - return true - } - - const all = getHistoryItems().filter((m: any) => m.role === 'assistant') - const target = all[arg ? Math.min(parseInt(arg, 10), all.length) - 1 : all.length - 1] - - if (!target) { - sys('nothing to copy') - - return true - } - - writeOsc52Clipboard(target.text) - sys('sent OSC52 copy sequence (terminal support required)') - - return true - } - - case 'paste': - if (!arg) { - paste() - - return true - } - - sys('usage: /paste') - - return true - case 'logs': { - const logText = ctx.gateway.gw.getLogTail(Math.min(80, Math.max(1, parseInt(arg, 10) || 20))) - - logText ? ctx.transcript.page(logText, 'Logs') : sys('no gateway logs') - - return true - } - - case 'statusbar': - case 'sb': { - const mode = arg.trim().toLowerCase() - - if (arg && !['on', 'off', 'toggle'].includes(mode)) { - sys('usage: /statusbar [on|off|toggle]') - - return true - } - - const next = mode === 'on' ? true : mode === 'off' ? false : !ui.statusBar - - patchUiState({ statusBar: next }) - ctx.gateway.rpc('config.set', { key: 'statusbar', value: next ? 'on' : 'off' }).catch(() => {}) - queueMicrotask(() => sys(`status bar ${next ? 'on' : 'off'}`)) - - return true - } - - case 'queue': - if (!arg) { - sys(`${queueRef.current.length} queued message(s)`) - - return true - } - - enqueue(arg) - sys(`queued: "${arg.slice(0, 50)}${arg.length > 50 ? '…' : ''}"`) - - return true - - case 'undo': - if (!sid) { - sys('nothing to undo') - - return true - } - - ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (isStaleSlash(ctx, flight, sid) || !r) { - return - } - - if (r.removed > 0) { - setHistoryItems((prev: any[]) => trimLastExchange(prev)) - sys(`undid ${r.removed} messages`) - } else { - sys('nothing to undo') - } - }) - - return true - case 'retry': { - const lastUserMsg = getLastUserMsg() - - if (!lastUserMsg) { - sys('nothing to retry') - - return true - } - - if (!sid) { - send(lastUserMsg) - - return true - } - - ctx.gateway.rpc('session.undo', { session_id: sid }).then((r: any) => { - if (isStaleSlash(ctx, flight, sid) || !r) { - return - } - - if (r.removed <= 0) { - sys('nothing to retry') - - return - } - - setHistoryItems((prev: any[]) => trimLastExchange(prev)) - send(lastUserMsg) - }) - - return true - } - } - - return false - } -} - -interface SlashCommand { - arg: string - flight: number - name: string - sid: null | string - ui: { - compact: boolean - detailsMode: DetailsMode - statusBar: boolean - } -} diff --git a/ui-tui/src/app/slash/createSlashOpsHandler.ts b/ui-tui/src/app/slash/createSlashOpsHandler.ts deleted file mode 100644 index 9b8a277be5..0000000000 --- a/ui-tui/src/app/slash/createSlashOpsHandler.ts +++ /dev/null @@ -1,456 +0,0 @@ -import type { ToolsConfigureResponse, ToolsListResponse, ToolsShowResponse } from '../../gatewayTypes.js' -import { rpcErrorMessage } from '../../lib/rpc.js' -import type { PanelSection } from '../../types.js' -import type { SlashHandlerContext } from '../interfaces.js' - -import { isStaleSlash } from './isStaleSlash.js' -import type { ParsedSlashCommand } from './shared.js' - -export function createSlashOpsHandler(ctx: SlashHandlerContext) { - const { rpc } = ctx.gateway - const { resetVisibleHistory, setSessionStartedAt } = ctx.session - const { panel, sys } = ctx.transcript - - return ({ arg, cmd, flight, name, sid }: OpsSlashCommand) => { - const stale = () => isStaleSlash(ctx, flight, sid) - - switch (name) { - case 'rollback': { - const [sub, ...rest] = (arg || 'list').split(/\s+/) - - if (!sub || sub === 'list') { - rpc('rollback.list', { session_id: sid }).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.checkpoints?.length) { - sys('no checkpoints') - - return - } - - panel('Checkpoints', [ - { - rows: r.checkpoints.map( - (c: any, i: number) => [`${i + 1} ${c.hash?.slice(0, 8)}`, c.message] as [string, string] - ) - } - ]) - }) - - return true - } - - const hash = sub === 'restore' || sub === 'diff' ? rest[0] : sub - const filePath = (sub === 'restore' || sub === 'diff' ? rest.slice(1) : rest).join(' ').trim() - - rpc(sub === 'diff' ? 'rollback.diff' : 'rollback.restore', { - session_id: sid, - hash, - ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(r.rendered || r.diff || r.message || 'done') - }) - - return true - } - - case 'browser': { - const [action, ...rest] = (arg || 'status').split(/\s+/) - - rpc('browser.manage', { action, ...(rest[0] ? { url: rest[0] } : {}) }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - }) - - return true - } - - case 'plugins': - rpc('plugins.list', {}).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.plugins?.length) { - sys('no plugins') - - return - } - - panel('Plugins', [ - { - items: r.plugins.map((p: any) => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) - } - ]) - }) - - return true - case 'skills': { - const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean) - - if (!sub || sub === 'list') { - rpc('skills.manage', { action: 'list' }).then((r: any) => { - if (stale() || !r) { - return - } - - const skills = r.skills as Record | undefined - - if (!skills || !Object.keys(skills).length) { - sys('no skills installed') - - return - } - - panel( - 'Installed Skills', - Object.entries(skills).map(([title, items]) => ({ items, title })) - ) - }) - - return true - } - - if (sub === 'browse') { - const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 - - rpc('skills.manage', { action: 'browse', page: pageNumber }).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.items?.length) { - sys('no skills found in the hub') - - return - } - - const sections: PanelSection[] = [ - { - rows: r.items.map( - (s: any) => - [s.name ?? '', (s.description ?? '').slice(0, 60) + (s.description?.length > 60 ? '…' : '')] as [ - string, - string - ] - ) - } - ] - - if (r.page < r.total_pages) { - sections.push({ text: `/skills browse ${r.page + 1} → next page` }) - } - - if (r.page > 1) { - sections.push({ text: `/skills browse ${r.page - 1} → prev page` }) - } - - panel(`Skills Hub (page ${r.page}/${r.total_pages}, ${r.total} total)`, sections) - }) - - return true - } - - ctx.gateway.gw - .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || '/skills: no output'}` - : r?.output || '/skills: no output' - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - case 'agents': - - case 'tasks': - rpc('agents.list', {}) - .then((r: any) => { - if (stale() || !r) { - return - } - - const processes = r.processes ?? [] - const running = processes.filter((p: any) => p.status === 'running') - const finished = processes.filter((p: any) => p.status !== 'running') - const sections: PanelSection[] = [] - - running.length && - sections.push({ - title: `Running (${running.length})`, - rows: running.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - finished.length && - sections.push({ - title: `Finished (${finished.length})`, - rows: finished.map((p: any) => [p.session_id.slice(0, 8), p.command]) - }) - !sections.length && sections.push({ text: 'No active processes' }) - panel('Agents', sections) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - - case 'cron': - if (!arg || arg === 'list') { - rpc('cron.manage', { action: 'list' }) - .then((r: any) => { - if (stale() || !r) { - return - } - - const jobs = r.jobs ?? [] - - if (!jobs.length) { - sys('no scheduled jobs') - - return - } - - panel('Cron', [ - { - rows: jobs.map( - (j: any) => - [j.name || j.job_id?.slice(0, 12), `${j.schedule} · ${j.state ?? 'active'}`] as [string, string] - ) - } - ]) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - } else { - ctx.gateway.gw - .request('slash.exec', { command: cmd.slice(1), session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - sys(r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)') - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - } - - return true - - case 'config': - rpc('config.show', {}) - .then((r: any) => { - if (stale() || !r) { - return - } - - panel( - 'Config', - (r.sections ?? []).map((s: any) => ({ - title: s.title, - rows: s.rows - })) - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - case 'tools': { - const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) - - if (!subcommand) { - rpc('tools.show', { session_id: sid }) - .then(r => { - if (stale()) { - return - } - - if (!r?.sections?.length) { - sys('no tools') - - return - } - - panel( - `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, - r.sections.map(section => ({ - title: section.name, - rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]) - })) - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - if (subcommand === 'list') { - rpc('tools.list', { session_id: sid }) - .then(r => { - if (stale()) { - return - } - - if (!r?.toolsets?.length) { - sys('no tools') - - return - } - - panel( - 'Tools', - r.toolsets.map(ts => ({ - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]`, - items: ts.tools - })) - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - if (subcommand === 'disable' || subcommand === 'enable') { - if (!names.length) { - sys(`usage: /tools ${subcommand} [name ...]`) - sys(`built-in toolset: /tools ${subcommand} web`) - sys(`MCP tool: /tools ${subcommand} github:create_issue`) - - return true - } - - rpc('tools.configure', { - action: subcommand, - names, - session_id: sid - }) - .then(r => { - if (stale() || !r) { - return - } - - if (r.info) { - setSessionStartedAt(Date.now()) - resetVisibleHistory(r.info) - } - - r.changed?.length && sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) - r.unknown?.length && sys(`unknown toolsets: ${r.unknown.join(', ')}`) - r.missing_servers?.length && sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) - r.reset && sys('session reset. new tool configuration is active.') - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - sys('usage: /tools [list|disable|enable] ...') - - return true - } - - case 'toolsets': - rpc('toolsets.list', { session_id: sid }) - .then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.toolsets?.length) { - sys('no toolsets') - - return - } - - panel('Toolsets', [ - { - rows: r.toolsets.map( - (ts: any) => - [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ - string, - string - ] - ) - } - ]) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - } - - return false - } -} - -interface OpsSlashCommand extends ParsedSlashCommand { - flight: number - sid: null | string -} diff --git a/ui-tui/src/app/slash/createSlashSessionHandler.ts b/ui-tui/src/app/slash/createSlashSessionHandler.ts deleted file mode 100644 index d4c1e404fd..0000000000 --- a/ui-tui/src/app/slash/createSlashSessionHandler.ts +++ /dev/null @@ -1,464 +0,0 @@ -import type { BackgroundStartResponse, SessionHistoryResponse } from '../../gatewayTypes.js' -import { rpcErrorMessage } from '../../lib/rpc.js' -import { fmtK } from '../../lib/text.js' -import type { PanelSection } from '../../types.js' -import { imageTokenMeta, introMsg, toTranscriptMessages } from '../helpers.js' -import type { SlashHandlerContext } from '../interfaces.js' -import { patchOverlayState } from '../overlayStore.js' -import { patchUiState } from '../uiStore.js' - -import { isStaleSlash } from './isStaleSlash.js' -import type { ParsedSlashCommand, SlashShared } from './shared.js' - -const SLASH_OUTPUT_PAGE: Record = { - debug: 'Debug', - fast: 'Fast', - platforms: 'Platforms', - snapshot: 'Snapshot' -} - -export function createSlashSessionHandler(ctx: SlashHandlerContext, shared: SlashShared) { - const { setInput } = ctx.composer - const { gw, rpc } = ctx.gateway - const { maybeWarn } = ctx.local - const { closeSession, guardBusySessionSwitch, resetVisibleHistory, setSessionStartedAt } = ctx.session - const { page, panel, setHistoryItems, sys } = ctx.transcript - const { setVoiceEnabled } = ctx.voice - - return ({ arg, cmd, flight, name, sid }: SessionSlashCommand) => { - const stale = () => isStaleSlash(ctx, flight, sid) - const pageTitle = SLASH_OUTPUT_PAGE[name] - - if (pageTitle) { - shared.showSlashOutput({ command: cmd.slice(1), flight, sid, title: pageTitle }) - - return true - } - - switch (name) { - case 'background': - - case 'bg': - if (!arg) { - sys('/background ') - - return true - } - - rpc('prompt.background', { session_id: sid, text: arg }).then(r => { - if (stale()) { - return - } - - const taskId = r?.task_id - - if (!taskId) { - return - } - - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add(taskId) })) - sys(`bg ${taskId} started`) - }) - - return true - - case 'btw': - if (!arg) { - sys('/btw ') - - return true - } - - rpc('prompt.btw', { session_id: sid, text: arg }).then((r: any) => { - if (stale() || !r) { - return - } - - patchUiState(state => ({ ...state, bgTasks: new Set(state.bgTasks).add('btw:x') })) - sys('btw running…') - }) - - return true - - case 'model': - if (guardBusySessionSwitch('change models')) { - return true - } - - if (!arg) { - patchOverlayState({ modelPicker: true }) - - return true - } - - rpc('config.set', { session_id: sid, key: 'model', value: arg.trim() }).then((r: any) => { - if (stale() || !r) { - return - } - - if (!r.value) { - sys('error: invalid response: model switch') - - return - } - - sys(`model → ${r.value}`) - maybeWarn(r) - patchUiState(state => ({ - ...state, - info: state.info ? { ...state.info, model: r.value } : { model: r.value, skills: {}, tools: {} } - })) - }) - - return true - - case 'image': - rpc('image.attach', { session_id: sid, path: arg }).then((r: any) => { - if (stale() || !r) { - return - } - - const meta = imageTokenMeta(r) - - sys(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - r?.remainder && setInput(r.remainder) - }) - - return true - - case 'provider': - gw.request('slash.exec', { command: 'provider', session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - page( - r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', - 'Provider' - ) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - - case 'skin': - if (arg) { - rpc('config.set', { key: 'skin', value: arg }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`skin → ${r.value}`) - }) - } else { - rpc('config.get', { key: 'skin' }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`skin: ${r.value || 'default'}`) - }) - } - - return true - - case 'yolo': - rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`yolo ${r.value === '1' ? 'on' : 'off'}`) - }) - - return true - - case 'reasoning': - if (!arg) { - rpc('config.get', { key: 'reasoning' }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) - }) - } else { - rpc('config.set', { session_id: sid, key: 'reasoning', value: arg }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`reasoning: ${r.value}`) - }) - } - - return true - - case 'verbose': - rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => { - if (stale() || !r?.value) { - return - } - - sys(`verbose: ${r.value}`) - }) - - return true - - case 'personality': - if (arg) { - rpc('config.set', { session_id: sid, key: 'personality', value: arg }).then((r: any) => { - if (stale() || !r) { - return - } - - r.history_reset && resetVisibleHistory(r.info ?? null) - sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) - maybeWarn(r) - }) - - return true - } - - gw.request('slash.exec', { command: 'personality', session_id: sid }) - .then((r: any) => { - if (stale()) { - return - } - - panel('Personality', [ - { - text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)' - } - ]) - }) - .catch((e: unknown) => { - if (stale()) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - - return true - - case 'compress': - rpc('session.compress', { session_id: sid, ...(arg ? { focus_topic: arg } : {}) }).then((r: any) => { - if (stale() || !r) { - return - } - - Array.isArray(r.messages) && - setHistoryItems( - r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages) - ) - r.info && patchUiState({ info: r.info }) - r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) - - if ((r.removed ?? 0) <= 0) { - sys('nothing to compress') - - return - } - - sys(`compressed ${r.removed} messages${r.usage?.total ? ` · ${fmtK(r.usage.total)} tok` : ''}`) - }) - - return true - - case 'stop': - rpc('process.stop', {}).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`killed ${r.killed ?? 0} registered process(es)`) - }) - - return true - - case 'branch': - case 'fork': { - const prevSid = sid - - rpc('session.branch', { session_id: sid, name: arg }).then((r: any) => { - if (stale() || !r?.session_id) { - return - } - - void closeSession(prevSid) - patchUiState({ sid: r.session_id }) - setSessionStartedAt(Date.now()) - setHistoryItems([]) - sys(`branched → ${r.title}`) - }) - - return true - } - - case 'reload-mcp': - - case 'reload_mcp': - rpc('reload.mcp', { session_id: sid }).then((r: any) => { - if (stale() || !r) { - return - } - - sys('MCP reloaded') - }) - - return true - - case 'title': - rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => { - if (stale() || !r) { - return - } - - sys(`title: ${r.title || '(none)'}`) - }) - - return true - - case 'usage': - rpc('session.usage', { session_id: sid }).then((r: any) => { - if (stale()) { - return - } - - if (r) { - patchUiState({ - usage: { input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0, calls: r.calls ?? 0 } - }) - } - - if (!r?.calls) { - sys('no API calls yet') - - return - } - - const f = (v: number) => (v ?? 0).toLocaleString() - - const cost = - r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null - - const rows: [string, string][] = [ - ['Model', r.model ?? ''], - ['Input tokens', f(r.input)], - ['Cache read tokens', f(r.cache_read)], - ['Cache write tokens', f(r.cache_write)], - ['Output tokens', f(r.output)], - ['Total tokens', f(r.total)], - ['API calls', f(r.calls)] - ] - - const sections: PanelSection[] = [{ rows }] - - cost && rows.push(['Cost', cost]) - r.context_max && - sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) - r.compressions && sections.push({ text: `Compressions: ${r.compressions}` }) - panel('Usage', sections) - }) - - return true - - case 'save': - rpc('session.save', { session_id: sid }).then((r: any) => { - if (stale() || !r?.file) { - return - } - - sys(`saved: ${r.file}`) - }) - - return true - - case 'history': - rpc('session.history', { session_id: sid }).then(r => { - if (stale() || typeof r?.count !== 'number') { - return - } - - if (!r.messages?.length) { - sys(`${r.count} messages`) - - return - } - - page( - r.messages - .map((msg, index) => - msg.role === 'tool' - ? `[Tool #${index + 1}] ${msg.name || 'tool'} ${msg.context || ''}`.trim() - : `[${msg.role === 'assistant' ? 'Hermes' : msg.role === 'user' ? 'You' : 'System'} #${index + 1}] ${msg.text || ''}`.trim() - ) - .join('\n\n'), - `History (${r.count})` - ) - }) - - return true - - case 'profile': - rpc('config.get', { key: 'profile' }).then((r: any) => { - if (stale() || !r) { - return - } - - const text = r.display || r.home || '(unknown profile)' - const lines = text.split('\n').filter(Boolean) - - lines.length <= 2 ? panel('Profile', [{ text }]) : page(text, 'Profile') - }) - - return true - - case 'voice': - rpc('voice.toggle', { action: arg === 'on' || arg === 'off' ? arg : 'status' }).then((r: any) => { - if (stale() || !r) { - return - } - - setVoiceEnabled(!!r?.enabled) - sys(`voice: ${r.enabled ? 'on' : 'off'}`) - }) - - return true - - case 'insights': - rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => { - if (stale() || !r) { - return - } - - panel('Insights', [ - { - rows: [ - ['Period', `${r.days} days`], - ['Sessions', `${r.sessions}`], - ['Messages', `${r.messages}`] - ] - } - ]) - }) - - return true - } - - return false - } -} - -interface SessionSlashCommand extends ParsedSlashCommand { - flight: number - sid: null | string -} diff --git a/ui-tui/src/app/slash/isStaleSlash.ts b/ui-tui/src/app/slash/isStaleSlash.ts deleted file mode 100644 index 0d8386fbd5..0000000000 --- a/ui-tui/src/app/slash/isStaleSlash.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { SlashHandlerContext } from '../interfaces.js' -import { getUiState } from '../uiStore.js' - -export function isStaleSlash( - ctx: Pick, - flight: number, - sid: null | string -): boolean { - return flight !== ctx.slashFlightRef.current || getUiState().sid !== sid -} diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts new file mode 100644 index 0000000000..3c7d1ee1d8 --- /dev/null +++ b/ui-tui/src/app/slash/registry.ts @@ -0,0 +1,18 @@ +import { coreCommands } from './commands/core.js' +import { opsCommands } from './commands/ops.js' +import { sessionCommands } from './commands/session.js' +import type { SlashCommand } from './types.js' + +export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands] + +const byName = new Map() + +for (const cmd of SLASH_COMMANDS) { + byName.set(cmd.name, cmd) + + for (const alias of cmd.aliases ?? []) { + byName.set(alias, cmd) + } +} + +export const findSlashCommand = (name: string): SlashCommand | undefined => byName.get(name.toLowerCase()) diff --git a/ui-tui/src/app/slash/shared.ts b/ui-tui/src/app/slash/shared.ts index e862045cf6..c6aba712b0 100644 --- a/ui-tui/src/app/slash/shared.ts +++ b/ui-tui/src/app/slash/shared.ts @@ -4,59 +4,35 @@ import type { SlashExecResponse } from '../../gatewayTypes.js' import { rpcErrorMessage } from '../../lib/rpc.js' import { getUiState } from '../uiStore.js' -export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { - const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) - - return { - arg: rest.join(' '), - cmd, - name: rawName.toLowerCase() - } -} - -export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({ - showSlashOutput: ({ command, flight, sid, title }) => { - gw.request('slash.exec', { command, session_id: sid }) - .then(r => { - if (flight !== slashFlightRef.current || getUiState().sid !== sid) { - return - } - - const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' - - const lines = text.split('\n').filter(Boolean) - - if (lines.length > 2 || text.length > 180) { - page(text, title) - } else { - sys(text) - } - }) - .catch((e: unknown) => { - if (flight !== slashFlightRef.current || getUiState().sid !== sid) { - return - } - - sys(`error: ${rpcErrorMessage(e)}`) - }) - } -}) - -export interface ParsedSlashCommand { - arg: string - cmd: string - name: string -} - export interface SlashShared { showSlashOutput: (opts: { command: string; flight: number; sid: null | string; title: string }) => void } interface SlashSharedDeps { - gw: { - request: (method: string, params?: Record) => Promise - } + gw: { request: (method: string, params?: Record) => Promise } page: (text: string, title?: string) => void slashFlightRef: MutableRefObject sys: (text: string) => void } + +export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({ + showSlashOutput: ({ command, flight, sid, title }) => { + const stale = () => flight !== slashFlightRef.current || getUiState().sid !== sid + + gw.request('slash.exec', { command, session_id: sid }) + .then(r => { + if (stale()) { + return + } + + const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' + + text.split('\n').filter(Boolean).length > 2 || text.length > 180 ? page(text, title) : sys(text) + }) + .catch((e: unknown) => { + if (!stale()) { + sys(`error: ${rpcErrorMessage(e)}`) + } + }) + } +}) diff --git a/ui-tui/src/app/slash/types.ts b/ui-tui/src/app/slash/types.ts new file mode 100644 index 0000000000..4fa6e0b595 --- /dev/null +++ b/ui-tui/src/app/slash/types.ts @@ -0,0 +1,24 @@ +import type { MutableRefObject } from 'react' + +import type { SlashHandlerContext, UiState } from '../interfaces.js' + +import type { SlashShared } from './shared.js' + +export interface SlashRunCtx extends SlashHandlerContext { + flight: number + guarded: (fn: (r: T) => void) => (r: null | T) => void + guardedErr: (e: unknown) => void + shared: SlashShared + sid: null | string + slashFlightRef: MutableRefObject + stale: () => boolean + ui: UiState +} + +export interface SlashCommand { + aliases?: string[] + help?: string + name: string + run: (arg: string, ctx: SlashRunCtx, cmd: string) => void + usage?: string +} diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts new file mode 100644 index 0000000000..df2814277f --- /dev/null +++ b/ui-tui/src/app/turnController.ts @@ -0,0 +1,353 @@ +import { REASONING_PULSE_MS, STREAM_BATCH_MS } from '../config/timing.js' +import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayTypes.js' +import { + buildToolTrailLine, + estimateTokensRough, + isToolTrailResultLine, + isTransientTrailLine, + sameToolTrailGroup, + toolTrailLabel +} from '../lib/text.js' +import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js' + +import { resetOverlayState } from './overlayStore.js' +import { patchTurnState, resetTurnState } from './turnStore.js' +import { patchUiState } from './uiStore.js' + +const INTERRUPT_COOLDOWN_MS = 1500 +const ACTIVITY_LIMIT = 8 +const TRAIL_LIMIT = 8 + +export interface InterruptDeps { + appendMessage: (msg: Msg) => void + gw: { request: (method: string, params?: Record) => Promise } + sid: string + sys: (text: string) => void +} + +type Timer = null | ReturnType + +const clear = (t: Timer): null => { + if (t) { + clearTimeout(t) + } + + return null +} + +class TurnController { + bufRef = '' + interrupted = false + lastStatusNote = '' + persistedToolLabels = new Set() + protocolWarned = false + reasoningText = '' + statusTimer: Timer = null + toolTokenAcc = 0 + turnTools: string[] = [] + + private activeTools: ActiveTool[] = [] + private activityId = 0 + private reasoningStreamingTimer: Timer = null + private reasoningTimer: Timer = null + private streamTimer: Timer = null + private toolProgressTimer: Timer = null + + clearReasoning() { + this.reasoningTimer = clear(this.reasoningTimer) + this.reasoningText = '' + this.toolTokenAcc = 0 + patchTurnState({ reasoning: '', reasoningTokens: 0, toolTokens: 0 }) + } + + clearStatusTimer() { + this.statusTimer = clear(this.statusTimer) + } + + endReasoningPhase() { + this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + patchTurnState({ reasoningActive: false, reasoningStreaming: false }) + } + + idle() { + this.endReasoningPhase() + this.activeTools = [] + this.streamTimer = clear(this.streamTimer) + this.bufRef = '' + + patchTurnState({ streaming: '', subagents: [], tools: [], turnTrail: [] }) + patchUiState({ busy: false }) + resetOverlayState() + } + + interruptTurn({ appendMessage, gw, sid, sys }: InterruptDeps) { + this.interrupted = true + gw.request('session.interrupt', { session_id: sid }).catch(() => {}) + + const partial = this.bufRef.trimStart() + + partial ? appendMessage({ role: 'assistant', text: `${partial}\n\n*[interrupted]*` }) : sys('interrupted') + + this.idle() + this.clearReasoning() + this.turnTools = [] + patchTurnState({ activity: [] }) + patchUiState({ status: 'interrupted' }) + this.clearStatusTimer() + + this.statusTimer = setTimeout(() => { + this.statusTimer = null + patchUiState({ status: 'ready' }) + }, INTERRUPT_COOLDOWN_MS) + } + + pruneTransient() { + this.turnTools = this.turnTools.filter(line => !isTransientTrailLine(line)) + patchTurnState(state => { + const next = state.turnTrail.filter(line => !isTransientTrailLine(line)) + + return next.length === state.turnTrail.length ? state : { ...state, turnTrail: next } + }) + } + + pulseReasoningStreaming() { + this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) + patchTurnState({ reasoningActive: true, reasoningStreaming: true }) + + this.reasoningStreamingTimer = setTimeout(() => { + this.reasoningStreamingTimer = null + patchTurnState({ reasoningStreaming: false }) + }, REASONING_PULSE_MS) + } + + pushActivity(text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) { + patchTurnState(state => { + const base = replaceLabel + ? state.activity.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) + : state.activity + + const tail = base.at(-1) + + if (tail?.text === text && tail.tone === tone) { + return state + } + + return { ...state, activity: [...base, { id: ++this.activityId, text, tone }].slice(-ACTIVITY_LIMIT) } + }) + } + + pushTrail(line: string) { + patchTurnState(state => { + if (state.turnTrail.at(-1) === line) { + return state + } + + const next = [...state.turnTrail.filter(item => !isTransientTrailLine(item)), line].slice(-TRAIL_LIMIT) + + this.turnTools = next + + return { ...state, turnTrail: next } + }) + } + + recordError() { + this.idle() + this.clearReasoning() + this.clearStatusTimer() + this.turnTools = [] + this.persistedToolLabels.clear() + } + + recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { + const finalText = (payload.rendered ?? payload.text ?? this.bufRef).trimStart() + const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() + const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 + const savedToolTokens = this.toolTokenAcc + const persisted = [...this.persistedToolLabels] + + const savedTools = this.turnTools.filter( + line => isToolTrailResultLine(line) && !persisted.some(label => sameToolTrailGroup(label, line)) + ) + + const wasInterrupted = this.interrupted + + this.idle() + this.clearReasoning() + this.turnTools = [] + this.persistedToolLabels.clear() + this.bufRef = '' + patchTurnState({ activity: [] }) + + return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } + } + + recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { + this.pruneTransient() + this.endReasoningPhase() + + if (!text || this.interrupted) { + return + } + + this.bufRef = rendered ?? this.bufRef + text + this.scheduleStreaming() + } + + recordReasoningAvailable(text: string) { + const incoming = text.trim() + + if (!incoming || this.reasoningText.trim()) { + return + } + + this.reasoningText = incoming + this.scheduleReasoning() + this.pulseReasoningStreaming() + } + + recordReasoningDelta(text: string) { + this.reasoningText += text + this.scheduleReasoning() + this.pulseReasoningStreaming() + } + + recordToolComplete(toolId: string, fallbackName?: string, error?: string, summary?: string) { + const done = this.activeTools.find(tool => tool.id === toolId) + const name = done?.name ?? fallbackName ?? 'tool' + const label = toolTrailLabel(name) + const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '') + + this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) + + const next = [...this.turnTools.filter(item => !sameToolTrailGroup(label, item)), line] + + if (!this.activeTools.length) { + next.push('analyzing tool output…') + } + + this.turnTools = next.slice(-TRAIL_LIMIT) + patchTurnState({ tools: this.activeTools, turnTrail: this.turnTools }) + } + + recordToolProgress(toolName: string, preview: string) { + const index = this.activeTools.findIndex(tool => tool.name === toolName) + + if (index < 0) { + return + } + + this.activeTools = this.activeTools.map((tool, i) => (i === index ? { ...tool, context: preview } : tool)) + + if (this.toolProgressTimer) { + return + } + + this.toolProgressTimer = setTimeout(() => { + this.toolProgressTimer = null + patchTurnState({ tools: [...this.activeTools] }) + }, STREAM_BATCH_MS) + } + + recordToolStart(toolId: string, name: string, context: string) { + this.pruneTransient() + this.endReasoningPhase() + + const sample = `${name} ${context}`.trim() + + this.toolTokenAcc += sample ? estimateTokensRough(sample) : 0 + this.activeTools = [...this.activeTools, { context, id: toolId, name, startedAt: Date.now() }] + + patchTurnState({ toolTokens: this.toolTokenAcc, tools: this.activeTools }) + } + + reset() { + this.clearReasoning() + this.clearStatusTimer() + this.idle() + this.bufRef = '' + this.interrupted = false + this.lastStatusNote = '' + this.protocolWarned = false + this.turnTools = [] + this.toolTokenAcc = 0 + this.persistedToolLabels.clear() + patchTurnState({ activity: [] }) + } + + fullReset() { + this.reset() + resetTurnState() + } + + scheduleReasoning() { + if (this.reasoningTimer) { + return + } + + this.reasoningTimer = setTimeout(() => { + this.reasoningTimer = null + patchTurnState({ + reasoning: this.reasoningText, + reasoningTokens: estimateTokensRough(this.reasoningText) + }) + }, STREAM_BATCH_MS) + } + + scheduleStreaming() { + if (this.streamTimer) { + return + } + + this.streamTimer = setTimeout(() => { + this.streamTimer = null + patchTurnState({ streaming: this.bufRef.trimStart() }) + }, STREAM_BATCH_MS) + } + + startMessage() { + this.endReasoningPhase() + this.clearReasoning() + this.activeTools = [] + this.turnTools = [] + this.toolTokenAcc = 0 + this.persistedToolLabels.clear() + patchUiState({ busy: true }) + patchTurnState({ activity: [], subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) + } + + upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial) { + const id = `sa:${p.task_index}:${p.goal || 'subagent'}` + + patchTurnState(state => { + const existing = state.subagents.find(item => item.id === id) + + const base: SubagentProgress = existing ?? { + goal: p.goal, + id, + index: p.task_index, + notes: [], + status: 'running', + taskCount: p.task_count ?? 1, + thinking: [], + tools: [] + } + + const next: SubagentProgress = { + ...base, + goal: p.goal || base.goal, + taskCount: p.task_count ?? base.taskCount, + ...patch(base) + } + + const subagents = existing + ? state.subagents.map(item => (item.id === id ? next : item)) + : [...state.subagents, next].sort((a, b) => a.index - b.index) + + return { ...state, subagents } + }) + } +} + +export const turnController = new TurnController() + +export type { TurnController } diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts new file mode 100644 index 0000000000..f4166ea8d1 --- /dev/null +++ b/ui-tui/src/app/turnStore.ts @@ -0,0 +1,47 @@ +import { atom } from 'nanostores' + +import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' + +export interface TurnState { + activity: ActivityItem[] + reasoning: string + reasoningActive: boolean + reasoningStreaming: boolean + reasoningTokens: number + streaming: string + subagents: SubagentProgress[] + toolTokens: number + tools: ActiveTool[] + turnTrail: string[] +} + +function buildTurnState(): TurnState { + return { + activity: [], + reasoning: '', + reasoningActive: false, + reasoningStreaming: false, + reasoningTokens: 0, + streaming: '', + subagents: [], + toolTokens: 0, + tools: [], + turnTrail: [] + } +} + +export const $turnState = atom(buildTurnState()) + +export const getTurnState = () => $turnState.get() + +export const patchTurnState = (next: Partial | ((state: TurnState) => TurnState)) => { + if (typeof next === 'function') { + $turnState.set(next($turnState.get())) + + return + } + + $turnState.set({ ...$turnState.get(), ...next }) +} + +export const resetTurnState = () => $turnState.set(buildTurnState()) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 501db36c92..868f2ba5e5 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -1,6 +1,6 @@ import { atom } from 'nanostores' -import { ZERO } from '../constants.js' +import { ZERO } from '../domain/usage.js' import { DEFAULT_THEME } from '../theme.js' import type { UiState } from './interfaces.js' diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index 467b01614e..a4ccb1f016 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -7,12 +7,12 @@ import { useStore } from '@nanostores/react' import { useCallback, useMemo, useState } from 'react' import type { PasteEvent } from '../components/textInput.js' +import { LARGE_PASTE } from '../config/limits.js' import { useCompletion } from '../hooks/useCompletion.js' import { useInputHistory } from '../hooks/useInputHistory.js' import { useQueue } from '../hooks/useQueue.js' import { pasteTokenLabel, stripTrailingPasteNewlines } from '../lib/text.js' -import { LARGE_PASTE } from './constants.js' import type { PasteSnippet, UseComposerStateOptions, UseComposerStateResult } from './interfaces.js' import { $isBlocked } from './overlayStore.js' diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts new file mode 100644 index 0000000000..6a6edb4df2 --- /dev/null +++ b/ui-tui/src/app/useConfigSync.ts @@ -0,0 +1,84 @@ +import { useEffect, useRef } from 'react' + +import { resolveDetailsMode } from '../domain/details.js' +import type { + ConfigFullResponse, + ConfigMtimeResponse, + ReloadMcpResponse, + VoiceToggleResponse +} from '../gatewayTypes.js' + +import type { GatewayRpc } from './interfaces.js' +import { turnController } from './turnController.js' +import { patchUiState } from './uiStore.js' + +const MTIME_POLL_MS = 5000 + +const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { + const display = cfg?.config?.display ?? {} + + setBell(!!display.bell_on_complete) + patchUiState({ + compact: !!display.tui_compact, + detailsMode: resolveDetailsMode(display), + statusBar: display.tui_statusbar !== false + }) +} + +export interface UseConfigSyncOptions { + rpc: GatewayRpc + setBellOnComplete: (v: boolean) => void + setVoiceEnabled: (v: boolean) => void + sid: null | string +} + +export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) { + const mtimeRef = useRef(0) + + useEffect(() => { + if (!sid) { + return + } + + rpc('voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled)) + rpc('config.get', { key: 'mtime' }).then(r => { + mtimeRef.current = Number(r?.mtime ?? 0) + }) + rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rpc, sid]) + + useEffect(() => { + if (!sid) { + return + } + + const id = setInterval(() => { + rpc('config.get', { key: 'mtime' }).then(r => { + const next = Number(r?.mtime ?? 0) + + if (!mtimeRef.current) { + if (next) { + mtimeRef.current = next + } + + return + } + + if (!next || next === mtimeRef.current) { + return + } + + mtimeRef.current = next + + rpc('reload.mcp', { session_id: sid }).then( + r => r && turnController.pushActivity('MCP reloaded after config change') + ) + rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + }) + }, MTIME_POLL_MS) + + return () => clearInterval(id) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rpc, sid]) +} diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 3f23d3e6c1..5359341504 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -1,77 +1,178 @@ import { useInput } from '@hermes/ink' import { useStore } from '@nanostores/react' +import type { + ApprovalRespondResponse, + SecretRespondResponse, + SudoRespondResponse, + VoiceRecordResponse +} from '../gatewayTypes.js' + import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' +const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target + export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { - const { actions, composer, gateway, terminal, turn, voice, wheelStep } = ctx + const { actions, composer, gateway, terminal, voice, wheelStep } = ctx + const { actions: cActions, refs: cRefs, state: cState } = composer + const overlay = useStore($overlayState) const isBlocked = useStore($isBlocked) const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) - const ctrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target - const copySelection = () => { if (terminal.selection.copySelection()) { actions.sys('copied selection') } } + const cancelOverlayFromCtrlC = (live: ReturnType) => { + if (overlay.clarify) { + return actions.answerClarify('') + } + + if (overlay.approval) { + return gateway + .rpc('approval.respond', { choice: 'deny', session_id: live.sid }) + .then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied'))) + } + + if (overlay.sudo) { + return gateway + .rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }) + .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled'))) + } + + if (overlay.secret) { + return gateway + .rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }) + .then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled'))) + } + + if (overlay.modelPicker) { + return patchOverlayState({ modelPicker: false }) + } + + if (overlay.picker) { + return patchOverlayState({ picker: false }) + } + } + + const cycleQueue = (dir: 1 | -1) => { + const len = cRefs.queueRef.current.length + + if (!len) { + return false + } + + const index = cState.queueEditIdx === null ? (dir > 0 ? 0 : len - 1) : (cState.queueEditIdx + dir + len) % len + + cActions.setQueueEdit(index) + cActions.setHistoryIdx(null) + cActions.setInput(cRefs.queueRef.current[index] ?? '') + + return true + } + + const cycleHistory = (dir: 1 | -1) => { + const h = cRefs.historyRef.current + const cur = cState.historyIdx + + if (dir < 0) { + if (!h.length) { + return + } + + if (cur === null) { + cRefs.historyDraftRef.current = cState.input + } + + const index = cur === null ? h.length - 1 : Math.max(0, cur - 1) + + cActions.setHistoryIdx(index) + cActions.setQueueEdit(null) + cActions.setInput(h[index] ?? '') + + return + } + + if (cur === null) { + return + } + + const next = cur + 1 + + if (next >= h.length) { + cActions.setHistoryIdx(null) + cActions.setInput(cRefs.historyDraftRef.current) + } else { + cActions.setHistoryIdx(next) + cActions.setInput(h[next] ?? '') + } + } + + const voiceStop = () => { + voice.setRecording(false) + voice.setProcessing(true) + + gateway + .rpc('voice.record', { action: 'stop' }) + .then(r => { + if (!r) { + return + } + + const transcript = String(r.text || '').trim() + + if (!transcript) { + return actions.sys('voice: no speech detected') + } + + cActions.setInput(prev => (prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript)) + }) + .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) + .finally(() => { + voice.setProcessing(false) + patchUiState({ status: 'ready' }) + }) + } + + const voiceStart = () => + gateway + .rpc('voice.record', { action: 'start' }) + .then(r => { + if (!r) { + return + } + + voice.setRecording(true) + patchUiState({ status: 'recording…' }) + }) + .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) + useInput((ch, key) => { const live = getUiState() if (isBlocked) { if (overlay.pager) { if (key.return || ch === ' ') { - const next = overlay.pager.offset + pagerPageSize + const nextOffset = overlay.pager.offset + pagerPageSize patchOverlayState({ - pager: next >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: next } + pager: nextOffset >= overlay.pager.lines.length ? null : { ...overlay.pager, offset: nextOffset } }) - } else if (key.escape || ctrl(key, ch, 'c') || ch === 'q') { + } else if (key.escape || isCtrl(key, ch, 'c') || ch === 'q') { patchOverlayState({ pager: null }) } return } - if (ctrl(key, ch, 'c')) { - if (overlay.clarify) { - actions.answerClarify('') - } else if (overlay.approval) { - gateway.rpc('approval.respond', { choice: 'deny', session_id: live.sid }).then(r => { - if (!r) { - return - } - - patchOverlayState({ approval: null }) - actions.sys('denied') - }) - } else if (overlay.sudo) { - gateway.rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }).then(r => { - if (!r) { - return - } - - patchOverlayState({ sudo: null }) - actions.sys('sudo cancelled') - }) - } else if (overlay.secret) { - gateway.rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }).then(r => { - if (!r) { - return - } - - patchOverlayState({ secret: null }) - actions.sys('secret entry cancelled') - }) - } else if (overlay.modelPicker) { - patchOverlayState({ modelPicker: false }) - } else if (overlay.picker) { - patchOverlayState({ picker: false }) - } + if (isCtrl(key, ch, 'c')) { + cancelOverlayFromCtrlC(live) } else if (key.escape && overlay.picker) { patchOverlayState({ picker: false }) } @@ -79,215 +180,116 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if ( - composer.state.completions.length && - composer.state.input && - composer.state.historyIdx === null && - (key.upArrow || key.downArrow) - ) { - composer.actions.setCompIdx(index => - key.upArrow - ? (index - 1 + composer.state.completions.length) % composer.state.completions.length - : (index + 1) % composer.state.completions.length - ) + if (cState.completions.length && cState.input && cState.historyIdx === null && (key.upArrow || key.downArrow)) { + const len = cState.completions.length + + cActions.setCompIdx(i => (key.upArrow ? (i - 1 + len) % len : (i + 1) % len)) return } if (key.wheelUp) { - terminal.scrollWithSelection(-wheelStep) - - return + return terminal.scrollWithSelection(-wheelStep) } if (key.wheelDown) { - terminal.scrollWithSelection(wheelStep) - - return + return terminal.scrollWithSelection(wheelStep) } if (key.shift && key.upArrow) { - terminal.scrollWithSelection(-1) - - return + return terminal.scrollWithSelection(-1) } if (key.shift && key.downArrow) { - terminal.scrollWithSelection(1) - - return + return terminal.scrollWithSelection(1) } if (key.pageUp || key.pageDown) { const viewport = terminal.scrollRef.current?.getViewportHeight() ?? Math.max(6, (terminal.stdout?.rows ?? 24) - 8) const step = Math.max(4, viewport - 2) - terminal.scrollWithSelection(key.pageUp ? -step : step) - - return + return terminal.scrollWithSelection(key.pageUp ? -step : step) } if (key.ctrl && key.shift && ch.toLowerCase() === 'c') { - copySelection() + return copySelection() + } + + if (key.upArrow && !cState.inputBuf.length) { + cycleQueue(1) || cycleHistory(-1) return } - if (key.upArrow && !composer.state.inputBuf.length) { - if (composer.refs.queueRef.current.length) { - const index = - composer.state.queueEditIdx === null - ? 0 - : (composer.state.queueEditIdx + 1) % composer.refs.queueRef.current.length - - composer.actions.setQueueEdit(index) - composer.actions.setHistoryIdx(null) - composer.actions.setInput(composer.refs.queueRef.current[index] ?? '') - } else if (composer.refs.historyRef.current.length) { - const index = - composer.state.historyIdx === null - ? composer.refs.historyRef.current.length - 1 - : Math.max(0, composer.state.historyIdx - 1) - - if (composer.state.historyIdx === null) { - composer.refs.historyDraftRef.current = composer.state.input - } - - composer.actions.setHistoryIdx(index) - composer.actions.setQueueEdit(null) - composer.actions.setInput(composer.refs.historyRef.current[index] ?? '') - } + if (key.downArrow && !cState.inputBuf.length) { + cycleQueue(-1) || cycleHistory(1) return } - if (key.downArrow && !composer.state.inputBuf.length) { - if (composer.refs.queueRef.current.length) { - const index = - composer.state.queueEditIdx === null - ? composer.refs.queueRef.current.length - 1 - : (composer.state.queueEditIdx - 1 + composer.refs.queueRef.current.length) % - composer.refs.queueRef.current.length - - composer.actions.setQueueEdit(index) - composer.actions.setHistoryIdx(null) - composer.actions.setInput(composer.refs.queueRef.current[index] ?? '') - } else if (composer.state.historyIdx !== null) { - const next = composer.state.historyIdx + 1 - - if (next >= composer.refs.historyRef.current.length) { - composer.actions.setHistoryIdx(null) - composer.actions.setInput(composer.refs.historyDraftRef.current) - } else { - composer.actions.setHistoryIdx(next) - composer.actions.setInput(composer.refs.historyRef.current[next] ?? '') - } - } - - return - } - - if (ctrl(key, ch, 'c')) { + if (isCtrl(key, ch, 'c')) { if (terminal.hasSelection) { - copySelection() - } else if (live.busy && live.sid) { - turn.actions.interruptTurn({ + return copySelection() + } + + if (live.busy && live.sid) { + return turnController.interruptTurn({ appendMessage: actions.appendMessage, gw: gateway.gw, sid: live.sid, sys: actions.sys }) - } else if (composer.state.input || composer.state.inputBuf.length) { - composer.actions.clearIn() - } else { - return actions.die() } - return - } + if (cState.input || cState.inputBuf.length) { + return cActions.clearIn() + } - if (ctrl(key, ch, 'd')) { return actions.die() } - if (ctrl(key, ch, 'l')) { + if (isCtrl(key, ch, 'd')) { + return actions.die() + } + + if (isCtrl(key, ch, 'l')) { if (actions.guardBusySessionSwitch()) { return } patchUiState({ status: 'forging session…' }) - actions.newSession() - return + return actions.newSession() } - if (ctrl(key, ch, 'b')) { - if (voice.recording) { - voice.setRecording(false) - voice.setProcessing(true) - gateway - .rpc('voice.record', { action: 'stop' }) - .then((r: any) => { - if (!r) { - return - } - - const transcript = String(r?.text || '').trim() - - if (transcript) { - composer.actions.setInput(prev => - prev ? `${prev}${/\s$/.test(prev) ? '' : ' '}${transcript}` : transcript - ) - } else { - actions.sys('voice: no speech detected') - } - }) - .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) - .finally(() => { - voice.setProcessing(false) - patchUiState({ status: 'ready' }) - }) - } else { - gateway - .rpc('voice.record', { action: 'start' }) - .then((r: any) => { - if (!r) { - return - } - - voice.setRecording(true) - patchUiState({ status: 'recording…' }) - }) - .catch((e: Error) => actions.sys(`voice error: ${e.message}`)) - } - - return + if (isCtrl(key, ch, 'b')) { + return voice.recording ? voiceStop() : voiceStart() } - if (ctrl(key, ch, 'g')) { - return composer.actions.openEditor() + if (isCtrl(key, ch, 'g')) { + return cActions.openEditor() } - if (key.tab && composer.state.completions.length) { - const row = composer.state.completions[composer.state.compIdx] + if (key.tab && cState.completions.length) { + const row = cState.completions[cState.compIdx] if (row?.text) { const text = - composer.state.input.startsWith('/') && row.text.startsWith('/') && composer.state.compReplace > 0 + cState.input.startsWith('/') && row.text.startsWith('/') && cState.compReplace > 0 ? row.text.slice(1) : row.text - composer.actions.setInput(composer.state.input.slice(0, composer.state.compReplace) + text) + cActions.setInput(cState.input.slice(0, cState.compReplace) + text) } return } - if (ctrl(key, ch, 'k') && composer.refs.queueRef.current.length && live.sid) { - const next = composer.actions.dequeue() + if (isCtrl(key, ch, 'k') && cRefs.queueRef.current.length && live.sid) { + const next = cActions.dequeue() if (next) { - composer.actions.setQueueEdit(null) + cActions.setQueueEdit(null) actions.dispatchSubmission(next) } } diff --git a/ui-tui/src/app/useLongRunToolCharms.ts b/ui-tui/src/app/useLongRunToolCharms.ts index 63583fb70d..60a871c193 100644 --- a/ui-tui/src/app/useLongRunToolCharms.ts +++ b/ui-tui/src/app/useLongRunToolCharms.ts @@ -1,23 +1,26 @@ import { useEffect, useRef } from 'react' -import { toolTrailLabel } from '../lib/text.js' -import type { ActiveTool, ActivityItem } from '../types.js' +import { LONG_RUN_CHARMS } from '../content/charms.js' +import { pick, toolTrailLabel } from '../lib/text.js' +import type { ActiveTool } from '../types.js' + +import { turnController } from './turnController.js' const DELAY_MS = 8_000 const INTERVAL_MS = 10_000 -const MAX = 2 -const CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] +const MAX_CHARMS_PER_TOOL = 2 -export function useLongRunToolCharms( - busy: boolean, - tools: ActiveTool[], - pushActivity: (text: string, tone?: ActivityItem['tone'], replaceLabel?: string) => void -) { - const slotRef = useRef(new Map()) +interface Slot { + count: number + lastAt: number +} + +export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) { + const slots = useRef(new Map()) useEffect(() => { if (!busy || !tools.length) { - slotRef.current.clear() + slots.current.clear() return } @@ -26,9 +29,9 @@ export function useLongRunToolCharms( const now = Date.now() const liveIds = new Set(tools.map(t => t.id)) - for (const key of [...slotRef.current.keys()]) { + for (const key of [...slots.current.keys()]) { if (!liveIds.has(key)) { - slotRef.current.delete(key) + slots.current.delete(key) } } @@ -37,20 +40,17 @@ export function useLongRunToolCharms( continue } - const slot = slotRef.current.get(tool.id) ?? { count: 0, lastAt: 0 } + const slot = slots.current.get(tool.id) ?? { count: 0, lastAt: 0 } - if (slot.count >= MAX || now - slot.lastAt < INTERVAL_MS) { + if (slot.count >= MAX_CHARMS_PER_TOOL || now - slot.lastAt < INTERVAL_MS) { continue } - slot.count += 1 - slot.lastAt = now - slotRef.current.set(tool.id, slot) + slots.current.set(tool.id, { count: slot.count + 1, lastAt: now }) - const charm = CHARMS[Math.floor(Math.random() * CHARMS.length)]! const sec = Math.round((now - tool.startedAt) / 1000) - pushActivity(`${charm} (${toolTrailLabel(tool.name)} · ${sec}s)`) + turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`) } } @@ -58,5 +58,5 @@ export function useLongRunToolCharms( const id = setInterval(tick, 1000) return () => clearInterval(id) - }, [busy, pushActivity, tools]) + }, [busy, tools]) } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 1abc4bdde1..f2827dfdfa 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -2,34 +2,69 @@ import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { INTERPOLATION_RE, ZERO } from '../constants.js' +import { STARTUP_RESUME_ID } from '../config/env.js' +import { MAX_HISTORY, WHEEL_SCROLL_STEP } from '../config/limits.js' +import { imageTokenMeta } from '../domain/messages.js' +import { shortCwd } from '../domain/paths.js' import { type GatewayClient } from '../gatewayClient.js' -import type { ConfigFullResponse, ConfigMtimeResponse, GatewayEvent, SessionCreateResponse } from '../gatewayTypes.js' +import type { + ClarifyRespondResponse, + ClipboardPasteResponse, + GatewayEvent, + TerminalResizeResponse +} from '../gatewayTypes.js' import { useVirtualHistory } from '../hooks/useVirtualHistory.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' -import { buildToolTrailLine, hasInterpolation, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' -import type { Msg, PanelSection, SessionInfo, SlashCatalog } from '../types.js' +import { buildToolTrailLine, sameToolTrailGroup, toolTrailLabel } from '../lib/text.js' +import type { Msg, PanelSection, SlashCatalog } from '../types.js' -import { MAX_HISTORY, PASTE_SNIPPET_RE, STARTUP_RESUME_ID, WHEEL_SCROLL_STEP } from './constants.js' import { createGatewayEventHandler } from './createGatewayEventHandler.js' import { createSlashHandler } from './createSlashHandler.js' -import { - imageTokenMeta, - introMsg, - looksLikeSlashCommand, - resolveDetailsMode, - shortCwd, - toTranscriptMessages -} from './helpers.js' import { type GatewayRpc, type TranscriptRow } from './interfaces.js' -import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' +import { $overlayState, patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { $turnState, patchTurnState } from './turnStore.js' import { $uiState, getUiState, patchUiState } from './uiStore.js' import { useComposerState } from './useComposerState.js' +import { useConfigSync } from './useConfigSync.js' import { useInputHandlers } from './useInputHandlers.js' import { useLongRunToolCharms } from './useLongRunToolCharms.js' -import { useTurnState } from './useTurnState.js' +import { useSessionLifecycle } from './useSessionLifecycle.js' +import { useSubmission } from './useSubmission.js' const GOOD_VIBES_RE = /\b(good bot|thanks|thank you|thx|ty|ily|love you)\b/i +const BRACKET_PASTE_ON = '\x1b[?2004h' +const BRACKET_PASTE_OFF = '\x1b[?2004l' + +const capHistory = (items: Msg[]): Msg[] => { + if (items.length <= MAX_HISTORY) { + return items + } + + return items[0]?.kind === 'intro' ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] : items.slice(-MAX_HISTORY) +} + +const statusColorOf = (status: string, t: { dim: string; error: string; ok: string; warn: string }) => { + if (status === 'ready') { + return t.ok + } + + if (status.startsWith('error')) { + return t.error + } + + if (status === 'interrupted') { + return t.warn + } + + return t.dim +} + +interface SelectionSnap { + anchor?: { row: number } + focus?: { row: number } + isDragging?: boolean +} export function useMainApp(gw: GatewayClient) { const { exit } = useApp() @@ -42,17 +77,18 @@ export function useMainApp(gw: GatewayClient) { } const sync = () => setCols(stdout.columns ?? 80) + stdout.on('resize', sync) if (stdout.isTTY) { - stdout.write('\x1b[?2004h') + stdout.write(BRACKET_PASTE_ON) } return () => { stdout.off('resize', sync) if (stdout.isTTY) { - stdout.write('\x1b[?2004l') + stdout.write(BRACKET_PASTE_OFF) } } }, [stdout]) @@ -60,40 +96,36 @@ export function useMainApp(gw: GatewayClient) { const [historyItems, setHistoryItems] = useState([]) const [lastUserMsg, setLastUserMsg] = useState('') const [stickyPrompt, setStickyPrompt] = useState('') - const [catalog, setCatalog] = useState(null) + const [catalog, setCatalog] = useState(null) const [voiceEnabled, setVoiceEnabled] = useState(false) const [voiceRecording, setVoiceRecording] = useState(false) const [voiceProcessing, setVoiceProcessing] = useState(false) const [sessionStartedAt, setSessionStartedAt] = useState(() => Date.now()) const [goodVibesTick, setGoodVibesTick] = useState(0) const [bellOnComplete, setBellOnComplete] = useState(false) + const ui = useStore($uiState) const overlay = useStore($overlayState) - const isBlocked = useStore($isBlocked) + const turn = useStore($turnState) const slashFlightRef = useRef(0) const slashRef = useRef<(cmd: string) => boolean>(() => false) - const lastEmptyAt = useRef(0) const colsRef = useRef(cols) - const scrollRef = useRef(null) + const scrollRef = useRef(null) const onEventRef = useRef<(ev: GatewayEvent) => void>(() => {}) const clipboardPasteRef = useRef<(quiet?: boolean) => Promise | void>(() => {}) const submitRef = useRef<(value: string) => void>(() => {}) - const configMtimeRef = useRef(0) const historyItemsRef = useRef(historyItems) const lastUserMsgRef = useRef(lastUserMsg) const msgIdsRef = useRef(new WeakMap()) const nextMsgIdRef = useRef(0) + colsRef.current = cols historyItemsRef.current = historyItems lastUserMsgRef.current = lastUserMsg const hasSelection = useHasSelection() const selection = useSelection() - const turn = useTurnState() - const turnActions = turn.actions - const turnRefs = turn.refs - const turnState = turn.state const composer = useComposerState({ gw, @@ -101,10 +133,7 @@ export function useMainApp(gw: GatewayClient) { submitRef }) - const composerActions = composer.actions - const composerRefs = composer.refs - const composerState = composer.state - + const { actions: composerActions, refs: composerRefs, state: composerState } = composer const empty = !historyItems.some(msg => msg.kind !== 'intro') const messageId = useCallback((msg: Msg) => { @@ -115,18 +144,14 @@ export function useMainApp(gw: GatewayClient) { } const next = `m${++nextMsgIdRef.current}` + msgIdsRef.current.set(msg, next) return next }, []) const virtualRows = useMemo( - () => - historyItems.map((msg, index) => ({ - index, - key: messageId(msg), - msg - })), + () => historyItems.map((msg, index) => ({ index, key: messageId(msg), msg })), [historyItems, messageId] ) @@ -136,31 +161,24 @@ export function useMainApp(gw: GatewayClient) { (delta: number) => { const s = scrollRef.current - const sel = selection.getState() as { - anchor?: { row: number } - focus?: { row: number } - isDragging?: boolean - } | null - - if (!s || !sel?.anchor || !sel.focus) { - s?.scrollBy(delta) - + if (!s) { return } + const sel = selection.getState() as null | SelectionSnap + + const focusOutside = (top: number, bottom: number) => + !sel?.anchor || + !sel.focus || + sel.anchor.row < top || + sel.anchor.row > bottom || + (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) + const top = s.getViewportTop() const bottom = top + s.getViewportHeight() - 1 - if (sel.anchor.row < top || sel.anchor.row > bottom) { - s.scrollBy(delta) - - return - } - - if (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) { - s.scrollBy(delta) - - return + if (focusOutside(top, bottom)) { + return s.scrollBy(delta) } const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) @@ -171,13 +189,14 @@ export function useMainApp(gw: GatewayClient) { return } + const shift = sel!.isDragging ? selection.shiftAnchor : selection.shiftSelection + if (actual > 0) { selection.captureScrolledRows(top, top + actual - 1, 'above') - sel.isDragging ? selection.shiftAnchor(-actual, top, bottom) : selection.shiftSelection(-actual, top, bottom) + shift(-actual, top, bottom) } else { - const amount = -actual - selection.captureScrolledRows(bottom - amount + 1, bottom, 'below') - sel.isDragging ? selection.shiftAnchor(amount, top, bottom) : selection.shiftSelection(amount, top, bottom) + selection.captureScrolledRows(bottom + actual + 1, bottom, 'below') + shift(-actual, top, bottom) } s.scrollBy(delta) @@ -185,57 +204,36 @@ export function useMainApp(gw: GatewayClient) { [selection] ) - const appendMessage = useCallback((msg: Msg) => { - const cap = (items: Msg[]) => - items.length <= MAX_HISTORY - ? items - : items[0]?.kind === 'intro' - ? [items[0]!, ...items.slice(-(MAX_HISTORY - 1))] - : items.slice(-MAX_HISTORY) + const appendMessage = useCallback((msg: Msg) => setHistoryItems(prev => capHistory([...prev, msg])), []) - setHistoryItems(prev => cap([...prev, msg])) - }, []) + const sys = useCallback((text: string) => appendMessage({ role: 'system', text }), [appendMessage]) - const sys = useCallback((text: string) => appendMessage({ role: 'system' as const, text }), [appendMessage]) - - const page = useCallback((text: string, title?: string) => { - const lines = text.split('\n') - patchOverlayState({ pager: { lines, offset: 0, title } }) - }, []) + const page = useCallback( + (text: string, title?: string) => patchOverlayState({ pager: { lines: text.split('\n'), offset: 0, title } }), + [] + ) const panel = useCallback( - (title: string, sections: PanelSection[]) => { - appendMessage({ role: 'system', text: '', kind: 'panel', panelData: { title, sections } }) - }, + (title: string, sections: PanelSection[]) => + appendMessage({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }), [appendMessage] ) const maybeWarn = useCallback( - (value: any) => { - if (value?.warning) { - sys(`warning: ${value.warning}`) + (value: unknown) => { + const warning = (value as { warning?: unknown } | null)?.warning + + if (typeof warning === 'string' && warning) { + sys(`warning: ${warning}`) } }, [sys] ) const maybeGoodVibes = useCallback((text: string) => { - if (!GOOD_VIBES_RE.test(text)) { - return + if (GOOD_VIBES_RE.test(text)) { + setGoodVibesTick(v => v + 1) } - - setGoodVibesTick(v => v + 1) - }, []) - - const applyDisplayConfig = useCallback((cfg: ConfigFullResponse | null) => { - const display = cfg?.config?.display ?? {} - - setBellOnComplete(!!display?.bell_on_complete) - patchUiState({ - compact: !!display?.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display?.tui_statusbar !== false - }) }, []) const rpc: GatewayRpc = useCallback( @@ -262,12 +260,35 @@ export function useMainApp(gw: GatewayClient) { const gateway = useMemo(() => ({ gw, rpc }), [gw, rpc]) + const die = useCallback(() => { + gw.kill() + exit() + }, [exit, gw]) + + const session = useSessionLifecycle({ + colsRef, + composerActions, + gw, + rpc, + setHistoryItems, + setLastUserMsg, + setSessionStartedAt, + setStickyPrompt, + setVoiceProcessing, + setVoiceRecording, + sys + }) + + useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) + useEffect(() => { if (!ui.sid || !stdout) { return } - const onResize = () => rpc('terminal.resize', { session_id: ui.sid, cols: stdout.columns ?? 80 }) + const onResize = () => + rpc('terminal.resize', { cols: stdout.columns ?? 80, session_id: ui.sid }) + stdout.on('resize', onResize) return () => { @@ -284,22 +305,21 @@ export function useMainApp(gw: GatewayClient) { } const label = toolTrailLabel('clarify') - const nextTrail = turnRefs.turnToolsRef.current.filter(line => !sameToolTrailGroup(label, line)) - turnRefs.turnToolsRef.current = nextTrail - turnActions.setTurnTrail(nextTrail) + turnController.turnTools = turnController.turnTools.filter(line => !sameToolTrailGroup(label, line)) + patchTurnState({ turnTrail: turnController.turnTools }) - rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { + rpc('clarify.respond', { answer, request_id: clarify.requestId }).then(r => { if (!r) { return } if (answer) { - turnRefs.persistedToolLabelsRef.current.add(label) + turnController.persistedToolLabels.add(label) appendMessage({ + kind: 'trail', role: 'system', text: '', - kind: 'trail', tools: [buildToolTrailLine('clarify', clarify.question)] }) appendMessage({ role: 'user', text: answer }) @@ -311,458 +331,43 @@ export function useMainApp(gw: GatewayClient) { patchOverlayState({ clarify: null }) }) }, - [appendMessage, overlay.clarify, rpc, sys, turnActions, turnRefs] - ) - - useEffect(() => { - if (!ui.sid) { - return - } - - rpc('voice.toggle', { action: 'status' }).then((r: any) => setVoiceEnabled(!!r?.enabled)) - rpc('config.get', { key: 'mtime' }).then(r => { - configMtimeRef.current = Number(r?.mtime ?? 0) - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - }, [applyDisplayConfig, rpc, ui.sid]) - - useEffect(() => { - if (!ui.sid) { - return - } - - const id = setInterval(() => { - rpc('config.get', { key: 'mtime' }).then(r => { - const next = Number(r?.mtime ?? 0) - - if (configMtimeRef.current && next && next !== configMtimeRef.current) { - configMtimeRef.current = next - rpc('reload.mcp', { session_id: ui.sid }).then(r => { - if (!r) { - return - } - - turnActions.pushActivity('MCP reloaded after config change') - }) - rpc('config.get', { key: 'full' }).then(applyDisplayConfig) - } else if (!configMtimeRef.current && next) { - configMtimeRef.current = next - } - }) - }, 5000) - - return () => clearInterval(id) - }, [applyDisplayConfig, turnActions, rpc, ui.sid]) - - const idle = turnActions.idle - const clearReasoning = turnActions.clearReasoning - - const die = useCallback(() => { - gw.kill() - exit() - }, [exit, gw]) - - const resetSession = useCallback(() => { - idle() - clearReasoning() - setVoiceRecording(false) - setVoiceProcessing(false) - patchUiState({ - bgTasks: new Set(), - info: null, - sid: null, - usage: ZERO - }) - setHistoryItems([]) - setLastUserMsg('') - setStickyPrompt('') - composerActions.setPasteSnips([]) - turnActions.setActivity([]) - turnRefs.turnToolsRef.current = [] - turnRefs.lastStatusNoteRef.current = '' - turnRefs.protocolWarnedRef.current = false - turnRefs.persistedToolLabelsRef.current.clear() - }, [clearReasoning, composerActions, idle, turnActions, turnRefs]) - - const resetVisibleHistory = useCallback( - (info: SessionInfo | null = null) => { - idle() - clearReasoning() - setHistoryItems(info ? [introMsg(info)] : []) - patchUiState({ - info, - usage: info?.usage ? { ...ZERO, ...info.usage } : ZERO - }) - setStickyPrompt('') - composerActions.setPasteSnips([]) - turnActions.setActivity([]) - setLastUserMsg('') - turnRefs.turnToolsRef.current = [] - turnRefs.persistedToolLabelsRef.current.clear() - }, - [clearReasoning, composerActions, idle, turnActions, turnRefs] - ) - - const trimLastExchange = useCallback((items: Msg[]) => { - const q = [...items] - - while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { - q.pop() - } - - if (q.at(-1)?.role === 'user') { - q.pop() - } - - return q - }, []) - - const guardBusySessionSwitch = useCallback( - (what = 'switch sessions') => { - if (!getUiState().busy) { - return false - } - - sys(`interrupt the current turn before trying to ${what}`) - - return true - }, - [sys] - ) - - const closeSession = useCallback( - (targetSid?: string | null) => { - if (!targetSid) { - return Promise.resolve(null) - } - - return rpc('session.close', { session_id: targetSid }) - }, - [rpc] - ) - - const newSession = useCallback( - async (msg?: string) => { - await closeSession(getUiState().sid) - - return rpc('session.create', { cols: colsRef.current }).then(r => { - if (!r) { - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO - }) - - if (r.info) { - setHistoryItems([introMsg(r.info)]) - } - - if (r.info?.credential_warning) { - sys(`warning: ${r.info.credential_warning}`) - } - - if (msg) { - sys(msg) - } - }) - }, - [closeSession, resetSession, rpc, sys] - ) - - const resumeById = useCallback( - (id: string) => { - patchOverlayState({ picker: false }) - patchUiState({ status: 'resuming…' }) - closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => - gw - .request('session.resume', { cols: colsRef.current, session_id: id }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: session.resume') - patchUiState({ status: 'ready' }) - - return - } - - resetSession() - setSessionStartedAt(Date.now()) - const resumed = toTranscriptMessages(r.messages) - - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ? { ...ZERO, ...r.info.usage } : ZERO - }) - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ status: 'ready' }) - }) - ) - }, - [closeSession, gw, resetSession, sys] + [appendMessage, overlay.clarify, rpc, sys] ) const paste = useCallback( (quiet = false) => - rpc('clipboard.paste', { session_id: getUiState().sid }).then((r: any) => { + rpc('clipboard.paste', { session_id: getUiState().sid }).then(r => { if (!r) { return } if (r.attached) { const meta = imageTokenMeta(r) - sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) - return + return sys(`📎 Image #${r.count} attached from clipboard${meta ? ` · ${meta}` : ''}`) } - quiet || sys(r.message || 'No image found in clipboard') + if (!quiet) { + sys(r.message || 'No image found in clipboard') + } }), [rpc, sys] ) clipboardPasteRef.current = paste - const handleTextPaste = composerActions.handleTextPaste - const send = useCallback( - (text: string) => { - const expandPasteSnips = (value: string) => { - const byLabel = new Map() - - for (const item of composerState.pasteSnips) { - const list = byLabel.get(item.label) - list ? list.push(item.text) : byLabel.set(item.label, [item.text]) - } - - return value.replace(PASTE_SNIPPET_RE, token => byLabel.get(token)?.shift() ?? token) - } - - const startSubmit = (displayText: string, submitText: string) => { - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - if (turnRefs.statusTimerRef.current) { - clearTimeout(turnRefs.statusTimerRef.current) - turnRefs.statusTimerRef.current = null - } - - maybeGoodVibes(submitText) - setLastUserMsg(text) - appendMessage({ role: 'user', text: displayText }) - patchUiState({ busy: true, status: 'running…' }) - turnRefs.bufRef.current = '' - turnRefs.interruptedRef.current = false - - gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ busy: false, status: 'ready' }) - }) - } - - const sid = getUiState().sid - - if (!sid) { - sys('session not ready yet') - - return - } - - gw.request('input.detect_drop', { session_id: sid, text }) - .then((r: any) => { - if (r?.matched) { - if (r.is_image) { - const meta = imageTokenMeta(r) - turnActions.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) - } else { - turnActions.pushActivity(`detected file: ${r.name}`) - } - - startSubmit(r.text || text, expandPasteSnips(r.text || text)) - - return - } - - startSubmit(text, expandPasteSnips(text)) - }) - .catch(() => startSubmit(text, expandPasteSnips(text))) - }, - [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, turnActions, sys, turnRefs] - ) - - const shellExec = useCallback( - (cmd: string) => { - appendMessage({ role: 'user', text: `!${cmd}` }) - patchUiState({ busy: true, status: 'running…' }) - - gw.request('shell.exec', { command: cmd }) - .then((raw: any) => { - const r = asRpcResult(raw) - - if (!r) { - sys('error: invalid response: shell.exec') - - return - } - - const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() - - if (out) { - sys(out) - } - - if (r.code !== 0 || !out) { - sys(`exit ${r.code}`) - } - }) - .catch((e: Error) => sys(`error: ${e.message}`)) - .finally(() => { - patchUiState({ busy: false, status: 'ready' }) - }) - }, - [appendMessage, gw, sys] - ) - - const openEditor = composerActions.openEditor - - const interpolate = useCallback( - (text: string, then: (result: string) => void) => { - patchUiState({ status: 'interpolating…' }) - const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] - - Promise.all( - matches.map(m => - gw - .request('shell.exec', { command: m[1]! }) - .then((raw: any) => { - const r = asRpcResult(raw) - - return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() - }) - .catch(() => '(error)') - ) - ).then(results => { - let out = text - - for (let i = matches.length - 1; i >= 0; i--) { - out = out.slice(0, matches[i]!.index!) + results[i] + out.slice(matches[i]!.index! + matches[i]![0].length) - } - - then(out) - }) - }, - [gw] - ) - - const sendQueued = useCallback( - (text: string) => { - if (text.startsWith('!')) { - shellExec(text.slice(1).trim()) - - return - } - - if (hasInterpolation(text)) { - patchUiState({ busy: true }) - interpolate(text, send) - - return - } - - send(text) - }, - [interpolate, send, shellExec] - ) - - const dispatchSubmission = useCallback( - (full: string) => { - const live = getUiState() - - if (!full.trim()) { - return - } - - if (!live.sid) { - sys('session not ready yet') - - return - } - - if (looksLikeSlashCommand(full)) { - appendMessage({ role: 'system', text: full, kind: 'slash' }) - composerActions.pushHistory(full) - slashRef.current(full) - composerActions.clearIn() - - return - } - - if (full.startsWith('!')) { - composerActions.clearIn() - shellExec(full.slice(1).trim()) - - return - } - - const editIdx = composerRefs.queueEditRef.current - composerActions.clearIn() - - if (editIdx !== null) { - composerActions.replaceQueue(editIdx, full) - const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] - composerActions.syncQueue() - composerActions.setQueueEdit(null) - - if (picked && getUiState().busy && live.sid) { - composerRefs.queueRef.current.unshift(picked) - composerActions.syncQueue() - - return - } - - if (picked && live.sid) { - sendQueued(picked) - } - - return - } - - composerActions.pushHistory(full) - - if (getUiState().busy) { - composerActions.enqueue(full) - - return - } - - if (hasInterpolation(full)) { - patchUiState({ busy: true }) - interpolate(full, send) - - return - } - - send(full) - }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, sys] - ) + const { dispatchSubmission, send, sendQueued, shellExec, submit } = useSubmission({ + appendMessage, + composerActions, + composerRefs, + composerState, + gw, + maybeGoodVibes, + setLastUserMsg, + slashRef, + submitRef, + sys + }) const { pagerPageSize } = useInputHandlers({ actions: { @@ -770,109 +375,43 @@ export function useMainApp(gw: GatewayClient) { appendMessage, die, dispatchSubmission, - guardBusySessionSwitch, - newSession, + guardBusySessionSwitch: session.guardBusySessionSwitch, + newSession: session.newSession, sys }, - composer: { - actions: composerActions, - refs: composerRefs, - state: composerState - }, + composer: { actions: composerActions, refs: composerRefs, state: composerState }, gateway, - terminal: { - hasSelection, - scrollRef, - scrollWithSelection, - selection, - stdout - }, - turn: { - actions: turnActions, - refs: turnRefs - }, - voice: { - recording: voiceRecording, - setProcessing: setVoiceProcessing, - setRecording: setVoiceRecording - }, + terminal: { hasSelection, scrollRef, scrollWithSelection, selection, stdout }, + voice: { recording: voiceRecording, setProcessing: setVoiceProcessing, setRecording: setVoiceRecording }, wheelStep: WHEEL_SCROLL_STEP }) const onEvent = useMemo( () => createGatewayEventHandler({ - composer: { - dequeue: composerActions.dequeue, - queueEditRef: composerRefs.queueEditRef, - sendQueued - }, + composer: { dequeue: composerActions.dequeue, queueEditRef: composerRefs.queueEditRef, sendQueued }, gateway, session: { STARTUP_RESUME_ID, colsRef, - newSession, - resetSession, + newSession: session.newSession, + resetSession: session.resetSession, setCatalog }, - system: { - bellOnComplete, - stdout, - sys - }, - transcript: { - appendMessage, - setHistoryItems - }, - turn: { - actions: { - clearReasoning, - endReasoningPhase: turnActions.endReasoningPhase, - idle, - pruneTransient: turnActions.pruneTransient, - pulseReasoningStreaming: turnActions.pulseReasoningStreaming, - pushActivity: turnActions.pushActivity, - pushTrail: turnActions.pushTrail, - scheduleReasoning: turnActions.scheduleReasoning, - scheduleStreaming: turnActions.scheduleStreaming, - setActivity: turnActions.setActivity, - setReasoningTokens: turnActions.setReasoningTokens, - setStreaming: turnActions.setStreaming, - setSubagents: turnActions.setSubagents, - setToolTokens: turnActions.setToolTokens, - setTools: turnActions.setTools, - setTurnTrail: turnActions.setTurnTrail - }, - refs: { - activeToolsRef: turnRefs.activeToolsRef, - bufRef: turnRefs.bufRef, - interruptedRef: turnRefs.interruptedRef, - lastStatusNoteRef: turnRefs.lastStatusNoteRef, - persistedToolLabelsRef: turnRefs.persistedToolLabelsRef, - protocolWarnedRef: turnRefs.protocolWarnedRef, - reasoningRef: turnRefs.reasoningRef, - statusTimerRef: turnRefs.statusTimerRef, - toolTokenAccRef: turnRefs.toolTokenAccRef, - toolCompleteRibbonRef: turnRefs.toolCompleteRibbonRef, - turnToolsRef: turnRefs.turnToolsRef - } - } + system: { bellOnComplete, stdout, sys }, + transcript: { appendMessage, setHistoryItems } }), [ appendMessage, bellOnComplete, - clearReasoning, composerActions, composerRefs, gateway, - idle, - newSession, - resetSession, sendQueued, - sys, - turnActions, - turnRefs, - stdout + session.newSession, + session.resetSession, + stdout, + sys ] ) @@ -883,7 +422,7 @@ export function useMainApp(gw: GatewayClient) { const exitHandler = () => { patchUiState({ busy: false, sid: null, status: 'gateway exited' }) - turnActions.pushActivity('gateway exited · /logs to inspect', 'error') + turnController.pushActivity('gateway exited · /logs to inspect', 'error') sys('error: gateway exited') } @@ -896,14 +435,13 @@ export function useMainApp(gw: GatewayClient) { gw.off('exit', exitHandler) gw.kill() } - }, [gw, turnActions, sys]) + }, [gw, sys]) - useLongRunToolCharms(ui.busy, turnState.tools, turnActions.pushActivity) + useLongRunToolCharms(ui.busy, turn.tools) const slash = useMemo( () => createSlashHandler({ - slashFlightRef, composer: { enqueue: composerActions.enqueue, hasSelection, @@ -920,163 +458,51 @@ export function useMainApp(gw: GatewayClient) { maybeWarn }, session: { - closeSession, + closeSession: session.closeSession, die, - guardBusySessionSwitch, - newSession, - resetVisibleHistory, - resumeById, + guardBusySessionSwitch: session.guardBusySessionSwitch, + newSession: session.newSession, + resetVisibleHistory: session.resetVisibleHistory, + resumeById: session.resumeById, setSessionStartedAt }, - transcript: { - page, - panel, - send, - setHistoryItems, - sys, - trimLastExchange - }, - voice: { - setVoiceEnabled - } + slashFlightRef, + transcript: { page, panel, send, setHistoryItems, sys, trimLastExchange: session.trimLastExchange }, + voice: { setVoiceEnabled } }), [ catalog, - closeSession, composerActions, composerRefs, die, gateway, - slashFlightRef, - guardBusySessionSwitch, hasSelection, maybeWarn, - newSession, page, panel, paste, - resetVisibleHistory, - resumeById, selection, send, - setSessionStartedAt, - setHistoryItems, - setVoiceEnabled, - sys, - trimLastExchange + session, + sys ] ) slashRef.current = slash - const submit = useCallback( - (value: string) => { - if (value.startsWith('/') && composerState.completions.length) { - const row = composerState.completions[composerState.compIdx] - - if (row?.text) { - const text = - value.startsWith('/') && row.text.startsWith('/') && composerState.compReplace > 0 - ? row.text.slice(1) - : row.text - - const next = value.slice(0, composerState.compReplace) + text - - if (next !== value) { - composerActions.setInput(next) - - return - } - } - } - - if (!value.trim() && !composerState.inputBuf.length) { - const live = getUiState() - const now = Date.now() - const dbl = now - lastEmptyAt.current < 450 - lastEmptyAt.current = now - - if (dbl && live.busy && live.sid) { - turnActions.interruptTurn({ - appendMessage, - gw, - sid: live.sid, - sys - }) - - return - } - - if (dbl && composerRefs.queueRef.current.length) { - const next = composerActions.dequeue() - - if (next && live.sid) { - composerActions.setQueueEdit(null) - dispatchSubmission(next) - } - } - - return - } - - lastEmptyAt.current = 0 - - if (value.endsWith('\\')) { - composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) - composerActions.setInput('') - - return - } - - dispatchSubmission([...composerState.inputBuf, value].join('\n')) - }, - [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys, turnActions] + const respondWith = useCallback( + (method: string, params: Record, done: () => void) => rpc(method, params).then(r => r && done()), + [rpc] ) - submitRef.current = submit - - const statusColor = - ui.status === 'ready' - ? ui.theme.color.ok - : ui.status.startsWith('error') - ? ui.theme.color.error - : ui.status === 'interrupted' - ? ui.theme.color.warn - : ui.theme.color.dim - - const sessionStarted = ui.sid ? sessionStartedAt : null - const voiceLabel = voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` - const cwdLabel = shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()) - const showStreamingArea = Boolean(turnState.streaming) - const showStickyPrompt = !!stickyPrompt - - const hasReasoning = Boolean(turnState.reasoning.trim()) - - const showProgressArea = - ui.detailsMode === 'hidden' - ? turnState.activity.some(item => item.tone !== 'info') - : Boolean( - ui.busy || - turnState.subagents.length || - turnState.tools.length || - turnState.turnTrail.length || - hasReasoning || - turnState.activity.length - ) - const answerApproval = useCallback( - (choice: string) => { - rpc('approval.respond', { choice, session_id: ui.sid }).then(r => { - if (!r) { - return - } - + (choice: string) => + respondWith('approval.respond', { choice, session_id: ui.sid }, () => { patchOverlayState({ approval: null }) sys(choice === 'deny' ? 'denied' : `approved (${choice})`) patchUiState({ status: 'running…' }) - }) - }, - [rpc, sys, ui.sid] + }), + [respondWith, sys, ui.sid] ) const answerSudo = useCallback( @@ -1085,16 +511,12 @@ export function useMainApp(gw: GatewayClient) { return } - rpc('sudo.respond', { request_id: overlay.sudo.requestId, password: pw }).then(r => { - if (!r) { - return - } - + return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) }, - [overlay.sudo, rpc] + [overlay.sudo, respondWith] ) const answerSecret = useCallback( @@ -1103,16 +525,12 @@ export function useMainApp(gw: GatewayClient) { return } - rpc('secret.respond', { request_id: overlay.secret.requestId, value }).then(r => { - if (!r) { - return - } - + return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) }, - [overlay.secret, rpc] + [overlay.secret, respondWith] ) const onModelSelect = useCallback((value: string) => { @@ -1120,6 +538,20 @@ export function useMainApp(gw: GatewayClient) { slashRef.current(`/model ${value}`) }, []) + const hasReasoning = Boolean(turn.reasoning.trim()) + + const showProgressArea = + ui.detailsMode === 'hidden' + ? turn.activity.some(item => item.tone !== 'info') + : Boolean( + ui.busy || + turn.subagents.length || + turn.tools.length || + turn.turnTrail.length || + hasReasoning || + turn.activity.length + ) + const appActions = useMemo( () => ({ answerApproval, @@ -1127,10 +559,10 @@ export function useMainApp(gw: GatewayClient) { answerSecret, answerSudo, onModelSelect, - resumeById, + resumeById: session.resumeById, setStickyPrompt }), - [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, resumeById, setStickyPrompt] + [answerApproval, answerClarify, answerSecret, answerSudo, onModelSelect, session.resumeById] ) const appComposer = useMemo( @@ -1139,7 +571,7 @@ export function useMainApp(gw: GatewayClient) { compIdx: composerState.compIdx, completions: composerState.completions, empty, - handleTextPaste, + handleTextPaste: composerActions.handleTextPaste, input: composerState.input, inputBuf: composerState.inputBuf, pagerPageSize, @@ -1148,69 +580,31 @@ export function useMainApp(gw: GatewayClient) { submit, updateInput: composerActions.setInput }), - [ - cols, - composerActions.setInput, - composerState.compIdx, - composerState.completions, - composerState.input, - composerState.inputBuf, - composerState.queueEditIdx, - composerState.queuedDisplay, - empty, - handleTextPaste, - pagerPageSize, - submit - ] + [cols, composerActions, composerState, empty, pagerPageSize, submit] ) const appProgress = useMemo( - () => ({ - activity: turnState.activity, - reasoning: turnState.reasoning, - reasoningActive: turnState.reasoningActive, - reasoningStreaming: turnState.reasoningStreaming, - reasoningTokens: turnState.reasoningTokens, - showProgressArea, - showStreamingArea, - streaming: turnState.streaming, - subagents: turnState.subagents, - toolTokens: turnState.toolTokens, - tools: turnState.tools, - turnTrail: turnState.turnTrail - }), - [showProgressArea, showStreamingArea, turnState] + () => ({ ...turn, showProgressArea, showStreamingArea: Boolean(turn.streaming) }), + [turn, showProgressArea] ) const appStatus = useMemo( () => ({ - cwdLabel, + cwdLabel: shortCwd(ui.info?.cwd || process.env.HERMES_CWD || process.cwd()), goodVibesTick, - sessionStartedAt: sessionStarted, - showStickyPrompt, - statusColor, + sessionStartedAt: ui.sid ? sessionStartedAt : null, + showStickyPrompt: !!stickyPrompt, + statusColor: statusColorOf(ui.status, ui.theme.color), stickyPrompt, - voiceLabel + voiceLabel: voiceRecording ? 'REC' : voiceProcessing ? 'STT' : `voice ${voiceEnabled ? 'on' : 'off'}` }), - [cwdLabel, goodVibesTick, sessionStarted, showStickyPrompt, statusColor, stickyPrompt, voiceLabel] + [goodVibesTick, sessionStartedAt, stickyPrompt, ui, voiceEnabled, voiceProcessing, voiceRecording] ) const appTranscript = useMemo( - () => ({ - historyItems, - scrollRef, - virtualHistory, - virtualRows - }), - [historyItems, scrollRef, virtualHistory, virtualRows] + () => ({ historyItems, scrollRef, virtualHistory, virtualRows }), + [historyItems, virtualHistory, virtualRows] ) - return { - appActions, - appComposer, - appProgress, - appStatus, - appTranscript, - gateway - } + return { appActions, appComposer, appProgress, appStatus, appTranscript, gateway } } diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts new file mode 100644 index 0000000000..bbde757cf2 --- /dev/null +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -0,0 +1,185 @@ +import { useCallback } from 'react' + +import { introMsg, toTranscriptMessages } from '../domain/messages.js' +import { ZERO } from '../domain/usage.js' +import { type GatewayClient } from '../gatewayClient.js' +import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse } from '../gatewayTypes.js' +import { asRpcResult } from '../lib/rpc.js' +import type { Msg, SessionInfo, Usage } from '../types.js' + +import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' +import { patchOverlayState } from './overlayStore.js' +import { turnController } from './turnController.js' +import { patchTurnState } from './turnStore.js' +import { getUiState, patchUiState } from './uiStore.js' + +const usageFrom = (info: null | SessionInfo): Usage => (info?.usage ? { ...ZERO, ...info.usage } : ZERO) + +const trimTail = (items: Msg[]) => { + const q = [...items] + + while (q.at(-1)?.role === 'assistant' || q.at(-1)?.role === 'tool') { + q.pop() + } + + if (q.at(-1)?.role === 'user') { + q.pop() + } + + return q +} + +export interface UseSessionLifecycleOptions { + colsRef: { current: number } + composerActions: ComposerActions + gw: GatewayClient + rpc: GatewayRpc + setHistoryItems: StateSetter + setLastUserMsg: StateSetter + setSessionStartedAt: StateSetter + setStickyPrompt: StateSetter + setVoiceProcessing: StateSetter + setVoiceRecording: StateSetter + sys: (text: string) => void +} + +export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { + const { + colsRef, + composerActions, + gw, + rpc, + setHistoryItems, + setLastUserMsg, + setSessionStartedAt, + setStickyPrompt, + setVoiceProcessing, + setVoiceRecording, + sys + } = opts + + const closeSession = useCallback( + (targetSid?: null | string) => + targetSid ? rpc('session.close', { session_id: targetSid }) : Promise.resolve(null), + [rpc] + ) + + const resetSession = useCallback(() => { + turnController.fullReset() + setVoiceRecording(false) + setVoiceProcessing(false) + patchUiState({ bgTasks: new Set(), info: null, sid: null, usage: ZERO }) + setHistoryItems([]) + setLastUserMsg('') + setStickyPrompt('') + composerActions.setPasteSnips([]) + }, [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt, setVoiceProcessing, setVoiceRecording]) + + const resetVisibleHistory = useCallback( + (info: null | SessionInfo = null) => { + turnController.idle() + turnController.clearReasoning() + turnController.turnTools = [] + turnController.persistedToolLabels.clear() + + setHistoryItems(info ? [introMsg(info)] : []) + setStickyPrompt('') + setLastUserMsg('') + composerActions.setPasteSnips([]) + patchTurnState({ activity: [] }) + patchUiState({ info, usage: usageFrom(info) }) + }, + [composerActions, setHistoryItems, setLastUserMsg, setStickyPrompt] + ) + + const newSession = useCallback( + async (msg?: string) => { + await closeSession(getUiState().sid) + + const r = await rpc('session.create', { cols: colsRef.current }) + + if (!r) { + return patchUiState({ status: 'ready' }) + } + + resetSession() + setSessionStartedAt(Date.now()) + patchUiState({ info: r.info ?? null, sid: r.session_id, status: 'ready', usage: usageFrom(r.info ?? null) }) + + if (r.info) { + setHistoryItems([introMsg(r.info)]) + } + + if (r.info?.credential_warning) { + sys(`warning: ${r.info.credential_warning}`) + } + + if (msg) { + sys(msg) + } + }, + [closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + ) + + const resumeById = useCallback( + (id: string) => { + patchOverlayState({ picker: false }) + patchUiState({ status: 'resuming…' }) + + closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + sys('error: invalid response: session.resume') + + return patchUiState({ status: 'ready' }) + } + + resetSession() + setSessionStartedAt(Date.now()) + + const resumed = toTranscriptMessages(r.messages) + + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: usageFrom(r.info ?? null) + }) + }) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + ) + }, + [closeSession, colsRef, gw, resetSession, setHistoryItems, setSessionStartedAt, sys] + ) + + const guardBusySessionSwitch = useCallback( + (what = 'switch sessions') => { + if (!getUiState().busy) { + return false + } + + sys(`interrupt the current turn before trying to ${what}`) + + return true + }, + [sys] + ) + + return { + closeSession, + guardBusySessionSwitch, + newSession, + resetSession, + resetVisibleHistory, + resumeById, + trimLastExchange: trimTail + } +} diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts new file mode 100644 index 0000000000..4fc699676e --- /dev/null +++ b/ui-tui/src/app/useSubmission.ts @@ -0,0 +1,300 @@ +import { type MutableRefObject, useCallback, useRef } from 'react' + +import { imageTokenMeta } from '../domain/messages.js' +import { looksLikeSlashCommand } from '../domain/slash.js' +import type { GatewayClient } from '../gatewayClient.js' +import type { InputDetectDropResponse, PromptSubmitResponse, ShellExecResponse } from '../gatewayTypes.js' +import { asRpcResult } from '../lib/rpc.js' +import { hasInterpolation, INTERPOLATION_RE } from '../protocol/interpolation.js' +import { PASTE_SNIPPET_RE } from '../protocol/paste.js' +import type { Msg } from '../types.js' + +import type { ComposerActions, ComposerRefs, ComposerState, PasteSnippet } from './interfaces.js' +import { turnController } from './turnController.js' +import { getUiState, patchUiState } from './uiStore.js' + +const DOUBLE_ENTER_MS = 450 + +const expandSnips = (snips: PasteSnippet[]) => { + const byLabel = new Map() + + for (const { label, text } of snips) { + const hit = byLabel.get(label) + hit ? hit.push(text) : byLabel.set(label, [text]) + } + + return (value: string) => value.replace(PASTE_SNIPPET_RE, tok => byLabel.get(tok)?.shift() ?? tok) +} + +const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => + matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text) + +export interface UseSubmissionOptions { + appendMessage: (msg: Msg) => void + composerActions: ComposerActions + composerRefs: ComposerRefs + composerState: ComposerState + gw: GatewayClient + maybeGoodVibes: (text: string) => void + setLastUserMsg: (value: string) => void + slashRef: MutableRefObject<(cmd: string) => boolean> + submitRef: MutableRefObject<(value: string) => void> + sys: (text: string) => void +} + +export function useSubmission(opts: UseSubmissionOptions) { + const { + appendMessage, + composerActions, + composerRefs, + composerState, + gw, + maybeGoodVibes, + setLastUserMsg, + slashRef, + submitRef, + sys + } = opts + + const lastEmptyAt = useRef(0) + + const send = useCallback( + (text: string) => { + const expand = expandSnips(composerState.pasteSnips) + + const startSubmit = (displayText: string, submitText: string) => { + const sid = getUiState().sid + + if (!sid) { + return sys('session not ready yet') + } + + turnController.clearStatusTimer() + maybeGoodVibes(submitText) + setLastUserMsg(text) + appendMessage({ role: 'user', text: displayText }) + patchUiState({ busy: true, status: 'running…' }) + turnController.bufRef = '' + turnController.interrupted = false + + gw.request('prompt.submit', { session_id: sid, text: submitText }).catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ busy: false, status: 'ready' }) + }) + } + + const sid = getUiState().sid + + if (!sid) { + return sys('session not ready yet') + } + + gw.request('input.detect_drop', { session_id: sid, text }) + .then(r => { + if (!r?.matched) { + return startSubmit(text, expand(text)) + } + + if (r.is_image) { + const meta = imageTokenMeta(r) + + turnController.pushActivity(`attached image: ${r.name}${meta ? ` · ${meta}` : ''}`) + } else { + turnController.pushActivity(`detected file: ${r.name}`) + } + + startSubmit(r.text || text, expand(r.text || text)) + }) + .catch(() => startSubmit(text, expand(text))) + }, + [appendMessage, composerState.pasteSnips, gw, maybeGoodVibes, setLastUserMsg, sys] + ) + + const shellExec = useCallback( + (cmd: string) => { + appendMessage({ role: 'user', text: `!${cmd}` }) + patchUiState({ busy: true, status: 'running…' }) + + gw.request('shell.exec', { command: cmd }) + .then(raw => { + const r = asRpcResult(raw) + + if (!r) { + return sys('error: invalid response: shell.exec') + } + + const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim() + + if (out) { + sys(out) + } + + if (r.code !== 0 || !out) { + sys(`exit ${r.code}`) + } + }) + .catch((e: Error) => sys(`error: ${e.message}`)) + .finally(() => patchUiState({ busy: false, status: 'ready' })) + }, + [appendMessage, gw, sys] + ) + + const interpolate = useCallback( + (text: string, then: (result: string) => void) => { + patchUiState({ status: 'interpolating…' }) + const matches = [...text.matchAll(new RegExp(INTERPOLATION_RE.source, 'g'))] + + Promise.all( + matches.map(m => + gw + .request('shell.exec', { command: m[1]! }) + .then(raw => { + const r = asRpcResult(raw) + + return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim() + }) + .catch(() => '(error)') + ) + ).then(results => then(spliceMatches(text, matches, results))) + }, + [gw] + ) + + const sendQueued = useCallback( + (text: string) => { + if (text.startsWith('!')) { + return shellExec(text.slice(1).trim()) + } + + if (hasInterpolation(text)) { + patchUiState({ busy: true }) + + return interpolate(text, send) + } + + send(text) + }, + [interpolate, send, shellExec] + ) + + const dispatchSubmission = useCallback( + (full: string) => { + if (!full.trim()) { + return + } + + const live = getUiState() + + if (!live.sid) { + return sys('session not ready yet') + } + + if (looksLikeSlashCommand(full)) { + appendMessage({ kind: 'slash', role: 'system', text: full }) + composerActions.pushHistory(full) + slashRef.current(full) + composerActions.clearIn() + + return + } + + if (full.startsWith('!')) { + composerActions.clearIn() + + return shellExec(full.slice(1).trim()) + } + + const editIdx = composerRefs.queueEditRef.current + composerActions.clearIn() + + if (editIdx !== null) { + composerActions.replaceQueue(editIdx, full) + const picked = composerRefs.queueRef.current.splice(editIdx, 1)[0] + composerActions.syncQueue() + composerActions.setQueueEdit(null) + + if (!picked || !live.sid) { + return + } + + if (getUiState().busy) { + composerRefs.queueRef.current.unshift(picked) + + return composerActions.syncQueue() + } + + return sendQueued(picked) + } + + composerActions.pushHistory(full) + + if (getUiState().busy) { + return composerActions.enqueue(full) + } + + if (hasInterpolation(full)) { + patchUiState({ busy: true }) + + return interpolate(full, send) + } + + send(full) + }, + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef, sys] + ) + + const submit = useCallback( + (value: string) => { + if (value.startsWith('/') && composerState.completions.length) { + const row = composerState.completions[composerState.compIdx] + + if (row?.text) { + const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text + + const next = value.slice(0, composerState.compReplace) + text + + if (next !== value) { + return composerActions.setInput(next) + } + } + } + + if (!value.trim() && !composerState.inputBuf.length) { + const live = getUiState() + const now = Date.now() + const doubleTap = now - lastEmptyAt.current < DOUBLE_ENTER_MS + lastEmptyAt.current = now + + if (doubleTap && live.busy && live.sid) { + return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }) + } + + if (doubleTap && composerRefs.queueRef.current.length) { + const next = composerActions.dequeue() + + if (next && live.sid) { + composerActions.setQueueEdit(null) + dispatchSubmission(next) + } + } + + return + } + + lastEmptyAt.current = 0 + + if (value.endsWith('\\')) { + composerActions.setInputBuf(prev => [...prev, value.slice(0, -1)]) + + return composerActions.setInput('') + } + + dispatchSubmission([...composerState.inputBuf, value].join('\n')) + }, + [appendMessage, composerActions, composerRefs, composerState, dispatchSubmission, gw, sys] + ) + + submitRef.current = submit + + return { dispatchSubmission, send, sendQueued, shellExec, submit } +} diff --git a/ui-tui/src/app/useTurnState.ts b/ui-tui/src/app/useTurnState.ts deleted file mode 100644 index c927773112..0000000000 --- a/ui-tui/src/app/useTurnState.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import { estimateTokensRough, isTransientTrailLine, sameToolTrailGroup } from '../lib/text.js' -import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' - -import { REASONING_PULSE_MS, STREAM_BATCH_MS } from './constants.js' -import type { InterruptTurnOptions, ToolCompleteRibbon, UseTurnStateResult } from './interfaces.js' -import { resetOverlayState } from './overlayStore.js' -import { patchUiState } from './uiStore.js' - -export function useTurnState(): UseTurnStateResult { - const [activity, setActivity] = useState([]) - const [reasoning, setReasoning] = useState('') - const [reasoningTokens, setReasoningTokens] = useState(0) - const [reasoningActive, setReasoningActive] = useState(false) - const [toolTokens, setToolTokens] = useState(0) - const [reasoningStreaming, setReasoningStreaming] = useState(false) - const [streaming, setStreaming] = useState('') - const [subagents, setSubagents] = useState([]) - const [tools, setTools] = useState([]) - const [turnTrail, setTurnTrail] = useState([]) - - const activityIdRef = useRef(0) - const activeToolsRef = useRef([]) - const bufRef = useRef('') - const interruptedRef = useRef(false) - const lastStatusNoteRef = useRef('') - const persistedToolLabelsRef = useRef>(new Set()) - const protocolWarnedRef = useRef(false) - const reasoningRef = useRef('') - const reasoningStreamingTimerRef = useRef | null>(null) - const reasoningTimerRef = useRef | null>(null) - const statusTimerRef = useRef | null>(null) - const streamTimerRef = useRef | null>(null) - const toolTokenAccRef = useRef(0) - const toolCompleteRibbonRef = useRef(null) - const turnToolsRef = useRef([]) - - const setTrail = (next: string[]) => { - turnToolsRef.current = next - - return next - } - - const pulseReasoningStreaming = useCallback(() => { - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - } - - setReasoningActive(true) - setReasoningStreaming(true) - reasoningStreamingTimerRef.current = setTimeout(() => { - reasoningStreamingTimerRef.current = null - setReasoningStreaming(false) - }, REASONING_PULSE_MS) - }, []) - - const scheduleStreaming = useCallback(() => { - if (streamTimerRef.current) { - return - } - - streamTimerRef.current = setTimeout(() => { - streamTimerRef.current = null - setStreaming(bufRef.current.trimStart()) - }, STREAM_BATCH_MS) - }, []) - - const scheduleReasoning = useCallback(() => { - if (reasoningTimerRef.current) { - return - } - - reasoningTimerRef.current = setTimeout(() => { - reasoningTimerRef.current = null - setReasoning(reasoningRef.current) - setReasoningTokens(estimateTokensRough(reasoningRef.current)) - }, STREAM_BATCH_MS) - }, []) - - const endReasoningPhase = useCallback(() => { - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - reasoningStreamingTimerRef.current = null - } - - setReasoningStreaming(false) - setReasoningActive(false) - }, []) - - useEffect( - () => () => { - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current) - } - - if (reasoningTimerRef.current) { - clearTimeout(reasoningTimerRef.current) - } - - if (reasoningStreamingTimerRef.current) { - clearTimeout(reasoningStreamingTimerRef.current) - } - }, - [] - ) - - const pushActivity = useCallback((text: string, tone: ActivityItem['tone'] = 'info', replaceLabel?: string) => { - setActivity(prev => { - const base = replaceLabel ? prev.filter(item => !sameToolTrailGroup(replaceLabel, item.text)) : prev - - if (base.at(-1)?.text === text && base.at(-1)?.tone === tone) { - return base - } - - activityIdRef.current++ - - return [...base, { id: activityIdRef.current, text, tone }].slice(-8) - }) - }, []) - - const pruneTransient = useCallback(() => { - setTurnTrail(prev => { - const next = prev.filter(line => !isTransientTrailLine(line)) - - return next.length === prev.length ? prev : setTrail(next) - }) - }, []) - - const pushTrail = useCallback((line: string) => { - setTurnTrail(prev => - prev.at(-1) === line ? prev : setTrail([...prev.filter(item => !isTransientTrailLine(item)), line].slice(-8)) - ) - }, []) - - const clearReasoning = useCallback(() => { - if (reasoningTimerRef.current) { - clearTimeout(reasoningTimerRef.current) - reasoningTimerRef.current = null - } - - reasoningRef.current = '' - toolTokenAccRef.current = 0 - setReasoning('') - setReasoningTokens(0) - setToolTokens(0) - }, []) - - const idle = useCallback(() => { - endReasoningPhase() - activeToolsRef.current = [] - setSubagents([]) - setTools([]) - setTurnTrail([]) - patchUiState({ busy: false }) - resetOverlayState() - - if (streamTimerRef.current) { - clearTimeout(streamTimerRef.current) - streamTimerRef.current = null - } - - setStreaming('') - bufRef.current = '' - }, [endReasoningPhase]) - - const interruptTurn = useCallback( - ({ appendMessage, gw, sid, sys }: InterruptTurnOptions) => { - interruptedRef.current = true - gw.request('session.interrupt', { session_id: sid }).catch(() => {}) - const partial = bufRef.current.trimStart() - - if (partial) { - appendMessage({ role: 'assistant', text: partial + '\n\n*[interrupted]*' }) - } else { - sys('interrupted') - } - - idle() - clearReasoning() - setActivity([]) - turnToolsRef.current = [] - patchUiState({ status: 'interrupted' }) - - if (statusTimerRef.current) { - clearTimeout(statusTimerRef.current) - } - - statusTimerRef.current = setTimeout(() => { - statusTimerRef.current = null - patchUiState({ status: 'ready' }) - }, 1500) - }, - [clearReasoning, idle] - ) - - const actions = useMemo( - () => ({ - clearReasoning, - endReasoningPhase, - idle, - interruptTurn, - pruneTransient, - pulseReasoningStreaming, - pushActivity, - pushTrail, - scheduleReasoning, - scheduleStreaming, - setActivity, - setReasoning, - setReasoningTokens, - setReasoningActive, - setToolTokens, - setReasoningStreaming, - setStreaming, - setSubagents, - setTools, - setTurnTrail - }), - [ - clearReasoning, - endReasoningPhase, - idle, - interruptTurn, - pruneTransient, - pulseReasoningStreaming, - pushActivity, - pushTrail, - scheduleReasoning, - scheduleStreaming - ] - ) - - const refs = useMemo( - () => ({ - activeToolsRef, - bufRef, - interruptedRef, - lastStatusNoteRef, - persistedToolLabelsRef, - protocolWarnedRef, - reasoningRef, - reasoningStreamingTimerRef, - reasoningTimerRef, - statusTimerRef, - streamTimerRef, - toolTokenAccRef, - toolCompleteRibbonRef, - turnToolsRef - }), - [] - ) - - const state = useMemo( - () => ({ - activity, - reasoning, - reasoningTokens, - reasoningActive, - toolTokens, - reasoningStreaming, - streaming, - subagents, - tools, - turnTrail - }), - [ - activity, - reasoning, - reasoningTokens, - reasoningActive, - toolTokens, - reasoningStreaming, - streaming, - subagents, - tools, - turnTrail - ] - ) - - return { - actions, - refs, - state - } -} diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index cad10f6489..4e55a53ba8 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,7 +1,8 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react' -import { fmtDuration, stickyPromptFromViewport } from '../app/helpers.js' +import { fmtDuration } from '../domain/messages.js' +import { stickyPromptFromViewport } from '../domain/viewport.js' import { fmtK } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' @@ -66,7 +67,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { return () => clearTimeout(id) }, [t.color.amber, tick]) - return {active ? '♥' : ' '} + return {active ? '♥' : ' '} } export function StatusRule({ @@ -108,29 +109,29 @@ export function StatusRule({ return ( - + {'─ '} - {status} - │ {model} - {ctxLabel ? │ {ctxLabel} : null} + {status} + │ {model} + {ctxLabel ? │ {ctxLabel} : null} {bar ? ( - + {' │ '} - [{bar}] {pctLabel} + [{bar}] {pctLabel} ) : null} {sessionStartedAt ? ( - + {' │ '} ) : null} - {voiceLabel ? │ {voiceLabel} : null} - {bgCount > 0 ? │ {bgCount} bg : null} + {voiceLabel ? │ {voiceLabel} : null} + {bgCount > 0 ? │ {bgCount} bg : null} - - {cwdLabel} + + {cwdLabel} ) } @@ -139,7 +140,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri return ( {!scrollable ? ( - + {' \n'.repeat(Math.max(0, vp - 1))}{' '} ) : ( <> {thumbTop > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, thumbTop - 1))}${thumbTop > 0 ? '│' : ''}`} ) : null} {thumb > 0 ? ( - {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} + {`${'┃\n'.repeat(Math.max(0, thumb - 1))}${thumb > 0 ? '┃' : ''}`} ) : null} {vp - thumbTop - thumb > 0 ? ( - + {`${'│\n'.repeat(Math.max(0, vp - thumbTop - thumb - 1))}${vp - thumbTop - thumb > 0 ? '│' : ''}`} ) : null} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 3d80e5fb1d..d517fa7a74 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -2,10 +2,10 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { memo } from 'react' -import { PLACEHOLDER } from '../app/constants.js' import type { AppLayoutProps } from '../app/interfaces.js' import { $isBlocked } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' +import { PLACEHOLDER } from '../content/placeholders.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { AppOverlays } from './appOverlays.js' @@ -119,14 +119,14 @@ const ComposerPane = memo(function ComposerPane({ /> {ui.bgTasks.size > 0 && ( - + {ui.bgTasks.size} background {ui.bgTasks.size === 1 ? 'task' : 'tasks'} running )} {status.showStickyPrompt ? ( - - + + {status.stickyPrompt} @@ -169,19 +169,19 @@ const ComposerPane = memo(function ComposerPane({ {composer.inputBuf.map((line, i) => ( - {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} + {i === 0 ? `${ui.theme.brand.prompt} ` : ' '} - {line || ' '} + {line || ' '} ))} {sh ? ( - $ + $ ) : ( - + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} )} @@ -204,7 +204,7 @@ const ComposerPane = memo(function ComposerPane({ )} - {!composer.empty && !ui.sid && ⚕ {ui.status}} + {!composer.empty && !ui.sid && ⚕ {ui.status}} ) }) diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 9b7f7b9dbf..75562eb9fd 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -112,7 +112,7 @@ export function AppOverlays({ {overlay.pager.title && ( - + {overlay.pager.title} @@ -123,7 +123,7 @@ export function AppOverlays({ ))} - + {overlay.pager.offset + pagerPageSize < overlay.pager.lines.length ? `Enter/Space for more · q to close (${Math.min(overlay.pager.offset + pagerPageSize, overlay.pager.lines.length)}/${overlay.pager.lines.length})` : `end · q to close (${overlay.pager.lines.length} lines)`} @@ -141,16 +141,16 @@ export function AppOverlays({ return ( - + {' '} {item.display} - {item.meta ? {item.meta} : null} + {item.meta ? {item.meta} : null} ) })} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 392b01c49a..541971a01f 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -1,8 +1,10 @@ import { Ansi, Box, NoSelect, Text } from '@hermes/ink' import { memo } from 'react' -import { LONG_MSG, ROLE } from '../constants.js' -import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi, userDisplay } from '../lib/text.js' +import { LONG_MSG } from '../config/limits.js' +import { userDisplay } from '../domain/messages.js' +import { ROLE } from '../domain/roles.js' +import { compactPreview, hasAnsi, isPasteBackedText, stripAnsi } from '../lib/text.js' import type { Theme } from '../theme.js' import type { DetailsMode, Msg } from '../types.js' diff --git a/ui-tui/src/components/themed.tsx b/ui-tui/src/components/themed.tsx new file mode 100644 index 0000000000..b007d78aa0 --- /dev/null +++ b/ui-tui/src/components/themed.tsx @@ -0,0 +1,45 @@ +import { Text } from '@hermes/ink' +import { useStore } from '@nanostores/react' +import type { ReactNode } from 'react' + +import { $uiState } from '../app/uiStore.js' +import type { ThemeColors } from '../theme.js' + +export type ThemeColor = keyof ThemeColors + +export interface FgProps { + bold?: boolean + c?: ThemeColor + children?: ReactNode + dim?: boolean + italic?: boolean + literal?: string + strikethrough?: boolean + underline?: boolean + wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim' +} + +/** + * Theme-aware text. `literal` wins; otherwise `c` is a palette key. + * + * hi // amber + * // dim cornsilk + * x // raw hex + */ +export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) { + const { theme } = useStore($uiState) + + return ( + + {children} + + ) +} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index ef3a7aba0d..76dbefe579 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -2,6 +2,7 @@ import { Box, NoSelect, Text } from '@hermes/ink' import { memo, type ReactNode, useEffect, useMemo, useState } from 'react' import spinners, { type BrailleSpinnerName } from 'unicode-animations' +import { THINKING_COT_MAX } from '../config/limits.js' import { compactPreview, estimateTokensRough, @@ -9,7 +10,6 @@ import { formatToolCall, parseToolTrailResultLine, pick, - THINKING_COT_MAX, thinkingPreview, toolTrailLabel } from '../lib/text.js' @@ -55,7 +55,7 @@ function TreeRow({ return ( - + {lead} @@ -84,11 +84,11 @@ function TreeTextRow({ wrap?: 'truncate-end' | 'wrap' | 'wrap-trim' }) { const text = dimColor ? ( - + {content} ) : ( - + {content} ) @@ -144,7 +144,7 @@ export function Spinner({ color, variant = 'think' }: { color: string; variant?: return () => clearInterval(id) }, [spin]) - return {spin.frames[frame]} + return {spin.frames[frame]} } interface DetailRow { @@ -195,11 +195,11 @@ function StreamCursor({ } return dimColor ? ( - + {streaming && on ? '▍' : ' '} ) : ( - {streaming && on ? '▍' : ' '} + {streaming && on ? '▍' : ' '} ) } @@ -224,12 +224,12 @@ function Chevron({ return ( onClick(!!e?.shiftKey || !!e?.ctrlKey)}> - - {open ? '▾ ' : '▸ '} + + {open ? '▾ ' : '▸ '} {title} {typeof count === 'number' ? ` (${count})` : ''} {suffix ? ( - + {' '} {suffix} @@ -366,7 +366,7 @@ function SubagentAccordion({ color={t.color.cornsilk} content={ <> - + {line} } @@ -501,7 +501,7 @@ export const Thinking = memo(function Thinking({ {preview ? ( mode === 'full' ? ( lines.map((line, index) => ( - + {line || ' '} {index === lines.length - 1 ? ( @@ -509,13 +509,13 @@ export const Thinking = memo(function Thinking({ )) ) : ( - + {preview} ) ) : ( - + )} @@ -715,7 +715,7 @@ export const ToolTrail = memo(function ToolTrail({ return alerts.length ? ( {alerts.map(i => ( - + {i.tone === 'error' ? '✗' : '!'} {i.text} ))} @@ -773,19 +773,19 @@ export const ToolTrail = memo(function ToolTrail({ } }} > - - {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} + + {detailsMode === 'expanded' || openThinking ? '▾ ' : '▸ '} {thinkingLive ? ( - + Thinking ) : ( - + Thinking )} {thinkingTokensLabel ? ( - + {' '} {thinkingTokensLabel} @@ -843,7 +843,7 @@ export const ToolTrail = memo(function ToolTrail({ color={group.color} content={ <> - + {group.content} } @@ -952,7 +952,7 @@ export const ToolTrail = memo(function ToolTrail({ color={t.color.statusFg} content={ <> - Σ + Σ {totalTokensLabel} } diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts new file mode 100644 index 0000000000..91da2121d3 --- /dev/null +++ b/ui-tui/src/config/env.ts @@ -0,0 +1,5 @@ +export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() + +export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test( + (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase() +) diff --git a/ui-tui/src/config/limits.ts b/ui-tui/src/config/limits.ts new file mode 100644 index 0000000000..aa1090396b --- /dev/null +++ b/ui-tui/src/config/limits.ts @@ -0,0 +1,5 @@ +export const LARGE_PASTE = { chars: 8000, lines: 80 } +export const LONG_MSG = 300 +export const MAX_HISTORY = 800 +export const THINKING_COT_MAX = 160 +export const WHEEL_SCROLL_STEP = 3 diff --git a/ui-tui/src/config/timing.ts b/ui-tui/src/config/timing.ts new file mode 100644 index 0000000000..63498dbae8 --- /dev/null +++ b/ui-tui/src/config/timing.ts @@ -0,0 +1,2 @@ +export const STREAM_BATCH_MS = 16 +export const REASONING_PULSE_MS = 700 diff --git a/ui-tui/src/constants.ts b/ui-tui/src/constants.ts deleted file mode 100644 index 9e8cb5a2ba..0000000000 --- a/ui-tui/src/constants.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Theme } from './theme.js' -import type { Role, Usage } from './types.js' - -export const FACES = [ - '(。•́︿•̀。)', - '(◔_◔)', - '(¬‿¬)', - '( •_•)>⌐■-■', - '(⌐■_■)', - '(´・_・`)', - '◉_◉', - '(°ロ°)', - '( ˘⌣˘)♡', - 'ヽ(>∀<☆)☆', - '٩(๑❛ᴗ❛๑)۶', - '(⊙_⊙)', - '(¬_¬)', - '( ͡° ͜ʖ ͡°)', - 'ಠ_ಠ' -] - -export const HOTKEYS: [string, string][] = [ - ['Ctrl+C', 'interrupt / clear draft / exit'], - ['Ctrl+D', 'exit'], - ['Ctrl+G', 'open $EDITOR for prompt'], - ['Ctrl+L', 'new session (clear)'], - ['Alt+V / /paste', 'paste clipboard image'], - ['Tab', 'apply completion'], - ['↑/↓', 'completions / queue edit / history'], - ['Ctrl+A/E', 'home / end of line'], - ['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'], - ['Ctrl+W', 'delete word'], - ['Ctrl+U/K', 'delete to start / end'], - ['Ctrl+←/→', 'jump word'], - ['Home/End', 'start / end of line'], - ['Shift+Enter / Alt+Enter', 'insert newline'], - ['\\+Enter', 'multi-line continuation (fallback)'], - ['!cmd', 'run shell command'], - ['{!cmd}', 'interpolate shell output inline'] -] - -export const INTERPOLATION_RE = /\{!(.+?)\}/g -export const LONG_MSG = 300 - -export 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?"' -] - -export const ROLE: Record { body: string; glyph: string; prefix: string }> = { - assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }), - system: t => ({ body: '', glyph: '·', prefix: t.color.dim }), - tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), - user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) -} - -export const TOOL_VERBS: Record = { - browser: 'browsing', - clarify: 'asking', - create_file: 'creating', - delegate_task: 'delegating', - delete_file: 'deleting', - execute_code: 'executing', - image_generate: 'generating', - list_files: 'listing', - memory: 'remembering', - patch: 'patching', - read_file: 'reading', - run_command: 'running', - search_code: 'searching', - search_files: 'searching', - terminal: 'terminal', - web_extract: 'extracting', - web_search: 'searching', - write_file: 'writing' -} - -export const VERBS = [ - 'pondering', - 'contemplating', - 'musing', - 'cogitating', - 'ruminating', - 'deliberating', - 'mulling', - 'reflecting', - 'processing', - 'reasoning', - 'analyzing', - 'computing', - 'synthesizing', - 'formulating', - 'brainstorming' -] - -export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/ui-tui/src/content/charms.ts b/ui-tui/src/content/charms.ts new file mode 100644 index 0000000000..546e44dd09 --- /dev/null +++ b/ui-tui/src/content/charms.ts @@ -0,0 +1 @@ +export const LONG_RUN_CHARMS = ['still cooking…', 'polishing edges…', 'asking the void nicely…'] diff --git a/ui-tui/src/content/faces.ts b/ui-tui/src/content/faces.ts new file mode 100644 index 0000000000..1bb64debb2 --- /dev/null +++ b/ui-tui/src/content/faces.ts @@ -0,0 +1,17 @@ +export const FACES = [ + '(。•́︿•̀。)', + '(◔_◔)', + '(¬‿¬)', + '( •_•)>⌐■-■', + '(⌐■_■)', + '(´・_・`)', + '◉_◉', + '(°ロ°)', + '( ˘⌣˘)♡', + 'ヽ(>∀<☆)☆', + '٩(๑❛ᴗ❛๑)۶', + '(⊙_⊙)', + '(¬_¬)', + '( ͡° ͜ʖ ͡°)', + 'ಠ_ಠ' +] diff --git a/ui-tui/src/content/fortunes.ts b/ui-tui/src/content/fortunes.ts new file mode 100644 index 0000000000..cd88dc4786 --- /dev/null +++ b/ui-tui/src/content/fortunes.ts @@ -0,0 +1,40 @@ +const FORTUNES = [ + 'you are one clean refactor away from clarity', + 'a tiny rename today prevents a huge bug tomorrow', + 'your next commit message will be immaculate', + 'the edge case you are ignoring is already solved in your head', + 'minimal diff, maximal calm', + 'today favors bold deletions over new abstractions', + 'the right helper is already in your codebase', + 'you will ship before overthinking catches up', + 'tests are about to save your future self', + 'your instincts are correctly suspicious of that one branch' +] + +const LEGENDARY_FORTUNES = [ + 'legendary drop: one-line fix, first try', + 'legendary drop: every flaky test passes cleanly', + 'legendary drop: your diff teaches by itself' +] + +const hash = (input: string) => { + let out = 2166136261 + + for (let i = 0; i < input.length; i++) { + out ^= input.charCodeAt(i) + out = Math.imul(out, 16777619) + } + + return out >>> 0 +} + +const fromScore = (score: number) => { + const rare = score % 20 === 0 + const bag = rare ? LEGENDARY_FORTUNES : FORTUNES + + return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` +} + +export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff)) + +export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`)) diff --git a/ui-tui/src/content/hotkeys.ts b/ui-tui/src/content/hotkeys.ts new file mode 100644 index 0000000000..f08ca61365 --- /dev/null +++ b/ui-tui/src/content/hotkeys.ts @@ -0,0 +1,19 @@ +export const HOTKEYS: [string, string][] = [ + ['Ctrl+C', 'interrupt / clear draft / exit'], + ['Ctrl+D', 'exit'], + ['Ctrl+G', 'open $EDITOR for prompt'], + ['Ctrl+L', 'new session (clear)'], + ['Alt+V / /paste', 'paste clipboard image'], + ['Tab', 'apply completion'], + ['↑/↓', 'completions / queue edit / history'], + ['Ctrl+A/E', 'home / end of line'], + ['Ctrl+Z / Ctrl+Y', 'undo / redo input edits'], + ['Ctrl+W', 'delete word'], + ['Ctrl+U/K', 'delete to start / end'], + ['Ctrl+←/→', 'jump word'], + ['Home/End', 'start / end of line'], + ['Shift+Enter / Alt+Enter', 'insert newline'], + ['\\+Enter', 'multi-line continuation (fallback)'], + ['!cmd', 'run shell command'], + ['{!cmd}', 'interpolate shell output inline'] +] diff --git a/ui-tui/src/content/placeholders.ts b/ui-tui/src/content/placeholders.ts new file mode 100644 index 0000000000..3d97eecac0 --- /dev/null +++ b/ui-tui/src/content/placeholders.ts @@ -0,0 +1,13 @@ +import { pick } from '../lib/text.js' + +export 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?"' +] + +export const PLACEHOLDER = pick(PLACEHOLDERS) diff --git a/ui-tui/src/content/verbs.ts b/ui-tui/src/content/verbs.ts new file mode 100644 index 0000000000..41b441d5cd --- /dev/null +++ b/ui-tui/src/content/verbs.ts @@ -0,0 +1,38 @@ +export const TOOL_VERBS: Record = { + browser: 'browsing', + clarify: 'asking', + create_file: 'creating', + delegate_task: 'delegating', + delete_file: 'deleting', + execute_code: 'executing', + image_generate: 'generating', + list_files: 'listing', + memory: 'remembering', + patch: 'patching', + read_file: 'reading', + run_command: 'running', + search_code: 'searching', + search_files: 'searching', + terminal: 'terminal', + web_extract: 'extracting', + web_search: 'searching', + write_file: 'writing' +} + +export const VERBS = [ + 'pondering', + 'contemplating', + 'musing', + 'cogitating', + 'ruminating', + 'deliberating', + 'mulling', + 'reflecting', + 'processing', + 'reasoning', + 'analyzing', + 'computing', + 'synthesizing', + 'formulating', + 'brainstorming' +] diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts new file mode 100644 index 0000000000..84c2cd80e7 --- /dev/null +++ b/ui-tui/src/domain/details.ts @@ -0,0 +1,29 @@ +import type { DetailsMode } from '../types.js' + +const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] + +const THINKING_FALLBACK: Record = { + collapsed: 'collapsed', + full: 'expanded', + truncated: 'collapsed' +} + +export const parseDetailsMode = (v: unknown): DetailsMode | null => { + const s = typeof v === 'string' ? v.trim().toLowerCase() : '' + + return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null +} + +export const resolveDetailsMode = ( + d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined +): DetailsMode => + parseDetailsMode(d?.details_mode) ?? + THINKING_FALLBACK[ + String(d?.thinking_mode ?? '') + .trim() + .toLowerCase() + ] ?? + 'collapsed' + +export const nextDetailsMode = (m: DetailsMode): DetailsMode => + DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts new file mode 100644 index 0000000000..2b7f4a513e --- /dev/null +++ b/ui-tui/src/domain/messages.ts @@ -0,0 +1,102 @@ +import { LONG_MSG } from '../config/limits.js' +import { buildToolTrailLine, fmtK } from '../lib/text.js' +import type { Msg, SessionInfo } from '../types.js' + +interface ImageMeta { + height?: number + token_estimate?: number + width?: number +} + +interface TranscriptRow { + context?: string + name?: string + role?: string + text?: string +} + +export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' }) + +export const imageTokenMeta = (info: ImageMeta | null | undefined) => + [ + info?.width && info.height ? `${info.width}x${info.height}` : '', + typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' + ] + .filter(Boolean) + .join(' · ') + +export const userDisplay = (text: string): string => { + if (text.length <= LONG_MSG) { + return text + } + + const first = text.split('\n')[0]?.trim() ?? '' + const words = first.split(/\s+/).filter(Boolean) + const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) + + return `${prefix || '(message)'} [long message]` +} + +export const toTranscriptMessages = (rows: unknown): Msg[] => { + if (!Array.isArray(rows)) { + return [] + } + + const result: Msg[] = [] + let pendingTools: string[] = [] + + for (const row of rows) { + if (!row || typeof row !== 'object') { + continue + } + + const { context, name, role, text } = row as TranscriptRow + + if (role === 'tool') { + pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? '')) + + continue + } + + if (typeof text !== 'string' || !text.trim()) { + continue + } + + if (role === 'assistant') { + const msg: Msg = { role, text } + + if (pendingTools.length) { + msg.tools = pendingTools + pendingTools = [] + } + + result.push(msg) + + continue + } + + if (role === 'user' || role === 'system') { + pendingTools = [] + result.push({ role, text }) + } + } + + return result +} + +export function fmtDuration(ms: number) { + const total = Math.max(0, Math.floor(ms / 1000)) + const hours = Math.floor(total / 3600) + const mins = Math.floor((total % 3600) / 60) + const secs = total % 60 + + if (hours > 0) { + return `${hours}h ${mins}m` + } + + if (mins > 0) { + return `${mins}m ${secs}s` + } + + return `${secs}s` +} diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts new file mode 100644 index 0000000000..120a71d79b --- /dev/null +++ b/ui-tui/src/domain/paths.ts @@ -0,0 +1,5 @@ +export const shortCwd = (cwd: string, max = 28) => { + const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd + + return p.length <= max ? p : `…${p.slice(-(max - 1))}` +} diff --git a/ui-tui/src/domain/roles.ts b/ui-tui/src/domain/roles.ts new file mode 100644 index 0000000000..f92d175e65 --- /dev/null +++ b/ui-tui/src/domain/roles.ts @@ -0,0 +1,9 @@ +import type { Theme } from '../theme.js' +import type { Role } from '../types.js' + +export const ROLE: Record { body: string; glyph: string; prefix: string }> = { + assistant: t => ({ body: t.color.cornsilk, glyph: t.brand.tool, prefix: t.color.bronze }), + system: t => ({ body: '', glyph: '·', prefix: t.color.dim }), + tool: t => ({ body: t.color.dim, glyph: '⚡', prefix: t.color.dim }), + user: t => ({ body: t.color.label, glyph: t.brand.prompt, prefix: t.color.label }) +} diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts new file mode 100644 index 0000000000..fd5b327d78 --- /dev/null +++ b/ui-tui/src/domain/slash.ts @@ -0,0 +1,25 @@ +export interface ParsedSlashCommand { + arg: string + cmd: string + name: string +} + +export const looksLikeSlashCommand = (text: string) => { + if (!text.startsWith('/')) { + return false + } + + const first = text.split(/\s+/, 1)[0] || '' + + return !first.slice(1).includes('/') +} + +export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { + const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) + + return { + arg: rest.join(' '), + cmd, + name: rawName.toLowerCase() + } +} diff --git a/ui-tui/src/domain/usage.ts b/ui-tui/src/domain/usage.ts new file mode 100644 index 0000000000..508195f253 --- /dev/null +++ b/ui-tui/src/domain/usage.ts @@ -0,0 +1,3 @@ +import type { Usage } from '../types.js' + +export const ZERO: Usage = { calls: 0, input: 0, output: 0, total: 0 } diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts new file mode 100644 index 0000000000..783bc52258 --- /dev/null +++ b/ui-tui/src/domain/viewport.ts @@ -0,0 +1,44 @@ +import type { Msg } from '../types.js' + +import { userDisplay } from './messages.js' + +const upperBound = (offsets: ArrayLike, target: number) => { + let lo = 0 + let hi = offsets.length + + while (lo < hi) { + const mid = (lo + hi) >> 1 + + offsets[mid]! <= target ? (lo = mid + 1) : (hi = mid) + } + + return lo +} + +export const stickyPromptFromViewport = ( + messages: readonly Msg[], + offsets: ArrayLike, + top: number, + sticky: boolean +) => { + if (sticky || !messages.length) { + return '' + } + + const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) + const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top + + if (messages[first]?.role === 'user' && !aboveViewport(first)) { + return '' + } + + for (let i = first - 1; i >= 0; i--) { + if (messages[i]?.role !== 'user' || !aboveViewport(i)) { + continue + } + + return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() + } + + return '' +} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 7fab065971..ee0e431230 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -20,6 +20,8 @@ export interface GatewayTranscriptMessage { text?: string } +// ── Commands / completion ──────────────────────────────────────────── + export interface CommandsCatalogResponse { canon?: Record categories?: SlashCategory[] @@ -34,6 +36,18 @@ export interface CompletionResponse { replace_from?: number } +export interface SlashExecResponse { + output?: string + warning?: string +} + +export type CommandDispatchResponse = + | { output?: string; type: 'exec' | 'plugin' } + | { target: string; type: 'alias' } + | { message?: string; name: string; type: 'skill' } + +// ── Config ─────────────────────────────────────────────────────────── + export interface ConfigDisplayConfig { bell_on_complete?: boolean details_mode?: string @@ -43,19 +57,29 @@ export interface ConfigDisplayConfig { } export interface ConfigFullResponse { - config?: { - display?: ConfigDisplayConfig - } + config?: { display?: ConfigDisplayConfig } } export interface ConfigMtimeResponse { mtime?: number } -export interface BackgroundStartResponse { - task_id?: string +export interface ConfigGetValueResponse { + display?: string + home?: string + value?: string } +export interface ConfigSetResponse { + credential_warning?: string + history_reset?: boolean + info?: SessionInfo + value?: string + warning?: string +} + +// ── Session lifecycle ──────────────────────────────────────────────── + export interface SessionCreateResponse { info?: SessionInfo & { credential_warning?: string } session_id: string @@ -91,21 +115,133 @@ export interface SessionHistoryResponse { messages?: GatewayTranscriptMessage[] } -export interface ModelOptionProvider { - is_current?: boolean - models?: string[] - name: string - slug: string - total_models?: number - warning?: string +export interface SessionCompressResponse { + info?: SessionInfo + messages?: GatewayTranscriptMessage[] + removed?: number + usage?: Usage } -export interface ModelOptionsResponse { - model?: string - provider?: string - providers?: ModelOptionProvider[] +export interface SessionBranchResponse { + session_id?: string + title?: string } +export interface SessionTitleResponse { + title?: string +} + +export interface SessionSaveResponse { + file?: string +} + +export interface SessionUsageResponse { + cache_read?: number + cache_write?: number + calls?: number + compressions?: number + context_max?: number + context_percent?: number + context_used?: number + cost_status?: 'estimated' | 'exact' + cost_usd?: number + input?: number + model?: string + output?: number + total?: number +} + +export interface SessionCloseResponse { + ok?: boolean +} + +export interface SessionInterruptResponse { + ok?: boolean +} + +// ── Prompt / submission ────────────────────────────────────────────── + +export interface PromptSubmitResponse { + ok?: boolean +} + +export interface BackgroundStartResponse { + task_id?: string +} + +export interface BtwStartResponse { + ok?: boolean +} + +export interface ClarifyRespondResponse { + ok?: boolean +} + +export interface ApprovalRespondResponse { + ok?: boolean +} + +export interface SudoRespondResponse { + ok?: boolean +} + +export interface SecretRespondResponse { + ok?: boolean +} + +// ── Shell / clipboard / input ──────────────────────────────────────── + +export interface ShellExecResponse { + code: number + stderr?: string + stdout?: string +} + +export interface ClipboardPasteResponse { + attached?: boolean + count?: number + height?: number + message?: string + token_estimate?: number + width?: number +} + +export interface InputDetectDropResponse { + height?: number + is_image?: boolean + matched?: boolean + name?: string + text?: string + token_estimate?: number + width?: number +} + +export interface TerminalResizeResponse { + ok?: boolean +} + +// ── Image attach ───────────────────────────────────────────────────── + +export interface ImageAttachResponse { + height?: number + name?: string + remainder?: string + token_estimate?: number + width?: number +} + +// ── Voice ──────────────────────────────────────────────────────────── + +export interface VoiceToggleResponse { + enabled?: boolean +} + +export interface VoiceRecordResponse { + text?: string +} + +// ── Tools / toolsets ───────────────────────────────────────────────── + export interface ToolsetDetails { description: string enabled: boolean @@ -142,15 +278,121 @@ export interface ToolsConfigureResponse { unknown?: string[] } -export interface SlashExecResponse { - output?: string +export interface ToolsetsListResponse { + toolsets?: { + description: string + enabled: boolean + name: string + tool_count: number + }[] +} + +// ── Ops: rollback / browser / plugins / skills / agents / cron ─────── + +export interface RollbackCheckpoint { + hash?: string + message?: string +} + +export interface RollbackListResponse { + checkpoints?: RollbackCheckpoint[] +} + +export interface RollbackActionResponse { + diff?: string + message?: string + rendered?: string +} + +export interface BrowserManageResponse { + connected?: boolean + url?: string +} + +export interface PluginInfo { + enabled?: boolean + name?: string + version?: string +} + +export interface PluginsListResponse { + plugins?: PluginInfo[] +} + +export interface SkillsListResponse { + skills?: Record +} + +export interface SkillsBrowseItem { + description?: string + name?: string +} + +export interface SkillsBrowseResponse { + items?: SkillsBrowseItem[] + page?: number + total?: number + total_pages?: number +} + +export interface AgentProcess { + command?: string + session_id: string + status?: 'finished' | 'running' +} + +export interface AgentsListResponse { + processes?: AgentProcess[] +} + +export interface CronJob { + job_id?: string + name?: string + schedule?: string + state?: string +} + +export interface CronListResponse { + jobs?: CronJob[] +} + +export interface ConfigShowSection { + rows?: [string, string][] + title?: string +} + +export interface ConfigShowResponse { + sections?: ConfigShowSection[] +} + +// ── Insights / MCP ─────────────────────────────────────────────────── + +export interface InsightsResponse { + days?: number + messages?: number + sessions?: number +} + +export interface ReloadMcpResponse { + ok?: boolean +} + +export interface ModelOptionProvider { + is_current?: boolean + models?: string[] + name: string + slug: string + total_models?: number warning?: string } -export type CommandDispatchResponse = - | { output?: string; type: 'exec' | 'plugin' } - | { target: string; type: 'alias' } - | { message?: string; name: string; type: 'skill' } +export interface ModelOptionsResponse { + model?: string + provider?: string + providers?: ModelOptionProvider[] +} + +// ── Subagent events ────────────────────────────────────────────────── export interface SubagentEventPayload { duration_seconds?: number diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index 9d6a9a58e0..c6b991a5ee 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,4 +1,4 @@ -import { INTERPOLATION_RE, LONG_MSG } from '../constants.js' +import { THINKING_COT_MAX } from '../config/limits.js' import type { ThinkingMode } from '../types.js' // eslint-disable-next-line no-control-regex @@ -73,7 +73,7 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { : `[[ ${preview} [${fmtK(lineCount)} lines] ]]` } -export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number) => { +export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { const raw = reasoning.trim() if (!raw || mode === 'collapsed') { @@ -155,8 +155,6 @@ export const lastCotTrailIndex = (trail: readonly string[]) => { return -1 } -export const THINKING_COT_MAX = 160 - export const estimateRows = (text: string, w: number, compact = false) => { let fence: { char: '`' | '~'; len: number } | null = null let rows = 0 @@ -213,25 +211,7 @@ const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) -export const hasInterpolation = (s: string) => { - INTERPOLATION_RE.lastIndex = 0 - - return INTERPOLATION_RE.test(s) -} - export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! -export const userDisplay = (text: string): string => { - if (text.length <= LONG_MSG) { - return text - } - - const first = text.split('\n')[0]?.trim() ?? '' - const words = first.split(/\s+/).filter(Boolean) - const prefix = (words.length > 1 ? words.slice(0, 4).join(' ') : first).slice(0, 80) - - return `${prefix || '(message)'} [long message]` -} - export const isPasteBackedText = (text: string): boolean => /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) diff --git a/ui-tui/src/protocol/interpolation.ts b/ui-tui/src/protocol/interpolation.ts new file mode 100644 index 0000000000..b83d16c5c2 --- /dev/null +++ b/ui-tui/src/protocol/interpolation.ts @@ -0,0 +1,7 @@ +export const INTERPOLATION_RE = /\{!(.+?)\}/g + +export const hasInterpolation = (s: string) => { + INTERPOLATION_RE.lastIndex = 0 + + return INTERPOLATION_RE.test(s) +} diff --git a/ui-tui/src/protocol/paste.ts b/ui-tui/src/protocol/paste.ts new file mode 100644 index 0000000000..9eae137cea --- /dev/null +++ b/ui-tui/src/protocol/paste.ts @@ -0,0 +1 @@ +export const PASTE_SNIPPET_RE = /\[\[[^\n]*?\]\]/g From 0478266831b36a8b4f0a27a067054ae9eaf22baf Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 14:26:15 -0500 Subject: [PATCH 122/157] =?UTF-8?q?refactor(tui):=20stop=20shadowing=20pyt?= =?UTF-8?q?hon=20=E2=80=94=20slash=20fallback=20inherits=20worker=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python's slash worker already prints every echo/panel command through Rich. TS was reformatting the same data client-side for 23 commands. Delete those shadows; let the `slash.exec` fallback in `createSlashHandler` route the worker's text (via ``) and page-wrap long output. TS registry now contains 23 commands (down from 45) — only those that: - mutate React-local state (composer, transcript, overlays, uiStore) - touch the terminal (OSC52 copy, `$EDITOR`, clipboard) - open pickers (`/model`, `/resume`) - trigger history surgery (`/undo`, `/retry`, `/compress`, `/personality`) - need TS-only composition (`/help` merges HOTKEYS + catalog) Deleted shadows: session: yolo, skin, verbose, reasoning, provider, stop, reload-mcp, save, title, insights, debug, fast, platforms, snapshot, usage, history, profile ops: plugins, rollback, agents, tasks, cron, config, toolsets, browser, skills (list/browse only; `/tools configure` kept for its history-reset side effect) Side effects: - Drops `slash/shared.ts` + `SlashShared` + `shared`/`SLASH_OUTPUT_PAGE` — generic slash.exec fallback handles titled paging via `createSlashHandler`. - Prunes 17 now-unreferenced `*Response` interfaces from gatewayTypes.ts. - `createSlashHandler` fallback now pages long output (len>180 || lines>2) and uses the command name as title. session.ts: 670 -> 199 (-70%) ops.ts: 460 -> 52 (-88%) gatewayTypes.ts: 450 -> 302 (-33%) --- ui-tui/src/app/createSlashHandler.ts | 19 +- ui-tui/src/app/slash/commands/ops.ts | 376 ++--------------------- ui-tui/src/app/slash/commands/session.ts | 305 ++---------------- ui-tui/src/app/slash/shared.ts | 38 --- ui-tui/src/app/slash/types.ts | 3 - ui-tui/src/gatewayTypes.ts | 163 +--------- 6 files changed, 69 insertions(+), 835 deletions(-) delete mode 100644 ui-tui/src/app/slash/shared.ts diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index c3ddab7a29..de77075db1 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -4,15 +4,17 @@ import { asCommandDispatch, rpcErrorMessage } from '../lib/rpc.js' import type { SlashHandlerContext } from './interfaces.js' import { findSlashCommand } from './slash/registry.js' -import { createSlashShared } from './slash/shared.js' import type { SlashRunCtx } from './slash/types.js' import { getUiState } from './uiStore.js' +const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1) + +const isLong = (text: string) => text.length > 180 || text.split('\n').filter(Boolean).length > 2 + export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { gw } = ctx.gateway const { catalog } = ctx.local - const { send, sys } = ctx.transcript - const shared = createSlashShared({ ...ctx.transcript, gw, slashFlightRef: ctx.slashFlightRef }) + const { page, send, sys } = ctx.transcript const handler = (cmd: string): boolean => { const flight = ++ctx.slashFlightRef.current @@ -37,7 +39,7 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b } } - const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, shared, sid, stale, ui } + const runCtx: SlashRunCtx = { ...ctx, flight, guarded, guardedErr, sid, stale, ui } const found = findSlashCommand(parsed.name) @@ -75,11 +77,10 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b return } - sys( - r?.warning - ? `warning: ${r.warning}\n${r?.output || `/${parsed.name}: no output`}` - : r?.output || `/${parsed.name}: no output` - ) + const body = r?.output || `/${parsed.name}: no output` + const text = r?.warning ? `warning: ${r.warning}\n${body}` : body + + isLong(text) ? page(text, titleCase(parsed.name)) : sys(text) }) .catch(() => { gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid }) diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index 3ea300ebed..c1f6c6d83b 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -1,365 +1,49 @@ -import type { - AgentsListResponse, - BrowserManageResponse, - ConfigShowResponse, - CronListResponse, - PluginsListResponse, - RollbackActionResponse, - RollbackListResponse, - SkillsBrowseResponse, - SkillsListResponse, - SlashExecResponse, - ToolsConfigureResponse, - ToolsetsListResponse, - ToolsListResponse, - ToolsShowResponse -} from '../../../gatewayTypes.js' -import type { PanelSection } from '../../../types.js' -import type { SlashCommand, SlashRunCtx } from '../types.js' - -const passthroughSlash = (ctx: SlashRunCtx, cmd: string, fallback: string) => - ctx.gateway.gw - .request('slash.exec', { command: cmd.slice(1), session_id: ctx.sid }) - .then(r => { - if (ctx.stale()) { - return - } - - ctx.transcript.sys(r?.warning ? `warning: ${r.warning}\n${r?.output || fallback}` : r?.output || fallback) - }) - .catch(ctx.guardedErr) - -const clip = (s: string, max: number) => (s.length > max ? `${s.slice(0, max)}…` : s) +import type { ToolsConfigureResponse } from '../../../gatewayTypes.js' +import type { SlashCommand } from '../types.js' export const opsCommands: SlashCommand[] = [ { - help: 'list or restore checkpoints', - name: 'rollback', - run: (arg, ctx) => { - const [sub, ...rest] = (arg || 'list').split(/\s+/) - - if (!sub || sub === 'list') { - return ctx.gateway.rpc('rollback.list', { session_id: ctx.sid }).then( - ctx.guarded(r => { - if (!r.checkpoints?.length) { - return ctx.transcript.sys('no checkpoints') - } - - ctx.transcript.panel('Checkpoints', [ - { - rows: r.checkpoints.map( - (c, i) => [`${i + 1} ${c.hash?.slice(0, 8) ?? ''}`, c.message ?? ''] as [string, string] - ) - } - ]) - }) - ) - } - - const isRestoreOrDiff = sub === 'restore' || sub === 'diff' - const hash = isRestoreOrDiff ? rest[0] : sub - const filePath = (isRestoreOrDiff ? rest.slice(1) : rest).join(' ').trim() - const method = sub === 'diff' ? 'rollback.diff' : 'rollback.restore' - - ctx.gateway - .rpc(method, { - hash, - session_id: ctx.sid, - ...(sub === 'diff' || !filePath ? {} : { file_path: filePath }) - }) - .then(ctx.guarded(r => ctx.transcript.sys(r.rendered || r.diff || r.message || 'done'))) - } - }, - - { - help: 'manage browser connection', - name: 'browser', - run: (arg, ctx) => { - const [action, url] = (arg || 'status').split(/\s+/) - - ctx.gateway - .rpc('browser.manage', { action, ...(url ? { url } : {}) }) - .then( - ctx.guarded(r => - ctx.transcript.sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected') - ) - ) - } - }, - - { - help: 'list installed plugins', - name: 'plugins', - run: (_arg, ctx) => { - ctx.gateway.rpc('plugins.list', {}).then( - ctx.guarded(r => { - if (!r.plugins?.length) { - return ctx.transcript.sys('no plugins') - } - - ctx.transcript.panel('Plugins', [ - { items: r.plugins.map(p => `${p.name} v${p.version}${p.enabled ? '' : ' (disabled)'}`) } - ]) - }) - ) - } - }, - - { - help: 'list or browse skills', - name: 'skills', - run: (arg, ctx, cmd) => { - const [sub, ...rest] = (arg || '').split(/\s+/).filter(Boolean) - - if (!sub || sub === 'list') { - return ctx.gateway.rpc('skills.manage', { action: 'list' }).then( - ctx.guarded(r => { - if (!r.skills || !Object.keys(r.skills).length) { - return ctx.transcript.sys('no skills installed') - } - - ctx.transcript.panel( - 'Installed Skills', - Object.entries(r.skills).map(([title, items]) => ({ items, title })) - ) - }) - ) - } - - if (sub === 'browse') { - const pageNumber = parseInt(rest[0] ?? '1', 10) || 1 - - return ctx.gateway.rpc('skills.manage', { action: 'browse', page: pageNumber }).then( - ctx.guarded(r => { - if (!r.items?.length) { - return ctx.transcript.sys('no skills found in the hub') - } - - const page = r.page ?? 1 - const totalPages = r.total_pages ?? 1 - - const sections: PanelSection[] = [ - { - rows: r.items.map(s => [s.name ?? '', clip(s.description ?? '', 60)] as [string, string]) - } - ] - - if (page < totalPages) { - sections.push({ text: `/skills browse ${page + 1} → next page` }) - } - - if (page > 1) { - sections.push({ text: `/skills browse ${page - 1} → prev page` }) - } - - ctx.transcript.panel(`Skills Hub (page ${page}/${totalPages}, ${r.total ?? 0} total)`, sections) - }) - ) - } - - passthroughSlash(ctx, cmd, '/skills: no output') - } - }, - - { - aliases: ['tasks'], - help: 'running agents', - name: 'agents', - run: (_arg, ctx) => { - ctx.gateway - .rpc('agents.list', {}) - .then( - ctx.guarded(r => { - const processes = r.processes ?? [] - const running = processes.filter(p => p.status === 'running') - const finished = processes.filter(p => p.status !== 'running') - const sections: PanelSection[] = [] - - if (running.length) { - sections.push({ - rows: running.map(p => [p.session_id.slice(0, 8), p.command ?? '']), - title: `Running (${running.length})` - }) - } - - if (finished.length) { - sections.push({ - rows: finished.map(p => [p.session_id.slice(0, 8), p.command ?? '']), - title: `Finished (${finished.length})` - }) - } - - if (!sections.length) { - sections.push({ text: 'No active processes' }) - } - - ctx.transcript.panel('Agents', sections) - }) - ) - .catch(ctx.guardedErr) - } - }, - - { - help: 'list or manage cron jobs', - name: 'cron', - run: (arg, ctx, cmd) => { - if (arg && arg !== 'list') { - return passthroughSlash(ctx, cmd, '(no output)') - } - - ctx.gateway - .rpc('cron.manage', { action: 'list' }) - .then( - ctx.guarded(r => { - const jobs = r.jobs ?? [] - - if (!jobs.length) { - return ctx.transcript.sys('no scheduled jobs') - } - - ctx.transcript.panel('Cron', [ - { - rows: jobs.map( - j => - [j.name || j.job_id?.slice(0, 12) || '', `${j.schedule ?? ''} · ${j.state ?? 'active'}`] as [ - string, - string - ] - ) - } - ]) - }) - ) - .catch(ctx.guardedErr) - } - }, - - { - help: 'show configuration', - name: 'config', - run: (_arg, ctx) => { - ctx.gateway - .rpc('config.show', {}) - .then( - ctx.guarded(r => - ctx.transcript.panel( - 'Config', - (r.sections ?? []).map(s => ({ rows: s.rows, title: s.title })) - ) - ) - ) - .catch(ctx.guardedErr) - } - }, - - { - help: 'list, enable, disable tools', + help: 'enable or disable tools (client-side history reset on change)', name: 'tools', run: (arg, ctx) => { const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) - if (!subcommand) { - return ctx.gateway - .rpc('tools.show', { session_id: ctx.sid }) - .then(r => { - if (ctx.stale()) { - return - } - - if (!r?.sections?.length) { - return ctx.transcript.sys('no tools') - } - - ctx.transcript.panel( - `Tools${typeof r.total === 'number' ? ` (${r.total})` : ''}`, - r.sections.map(section => ({ - rows: section.tools.map(tool => [tool.name, tool.description] as [string, string]), - title: section.name - })) - ) - }) - .catch(ctx.guardedErr) + if (subcommand !== 'disable' && subcommand !== 'enable') { + return // py prints lists / show / usage } - if (subcommand === 'list') { - return ctx.gateway - .rpc('tools.list', { session_id: ctx.sid }) - .then(r => { - if (ctx.stale()) { - return - } + if (!names.length) { + ctx.transcript.sys(`usage: /tools ${subcommand} [name ...]`) + ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`) + ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`) - if (!r?.toolsets?.length) { - return ctx.transcript.sys('no tools') - } - - ctx.transcript.panel( - 'Tools', - r.toolsets.map(ts => ({ - items: ts.tools, - title: `${ts.enabled ? '*' : ' '} ${ts.name} [${ts.tool_count} tools]` - })) - ) - }) - .catch(ctx.guardedErr) + return } - if (subcommand === 'disable' || subcommand === 'enable') { - if (!names.length) { - ctx.transcript.sys(`usage: /tools ${subcommand} [name ...]`) - ctx.transcript.sys(`built-in toolset: /tools ${subcommand} web`) - ctx.transcript.sys(`MCP tool: /tools ${subcommand} github:create_issue`) - - return - } - - return ctx.gateway - .rpc('tools.configure', { action: subcommand, names, session_id: ctx.sid }) - .then( - ctx.guarded(r => { - if (r.info) { - ctx.session.setSessionStartedAt(Date.now()) - ctx.session.resetVisibleHistory(r.info) - } - - r.changed?.length && - ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) - r.unknown?.length && ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`) - r.missing_servers?.length && ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) - r.reset && ctx.transcript.sys('session reset. new tool configuration is active.') - }) - ) - .catch(ctx.guardedErr) - } - - ctx.transcript.sys('usage: /tools [list|disable|enable] ...') - } - }, - - { - help: 'list toolsets', - name: 'toolsets', - run: (_arg, ctx) => { ctx.gateway - .rpc('toolsets.list', { session_id: ctx.sid }) + .rpc('tools.configure', { action: subcommand, names, session_id: ctx.sid }) .then( - ctx.guarded(r => { - if (!r.toolsets?.length) { - return ctx.transcript.sys('no toolsets') + ctx.guarded(r => { + if (r.info) { + ctx.session.setSessionStartedAt(Date.now()) + ctx.session.resetVisibleHistory(r.info) } - ctx.transcript.panel('Toolsets', [ - { - rows: r.toolsets.map( - ts => - [`${ts.enabled ? '(*)' : ' '} ${ts.name}`, `[${ts.tool_count}] ${ts.description}`] as [ - string, - string - ] - ) - } - ]) + if (r.changed?.length) { + ctx.transcript.sys(`${subcommand === 'disable' ? 'disabled' : 'enabled'}: ${r.changed.join(', ')}`) + } + + if (r.unknown?.length) { + ctx.transcript.sys(`unknown toolsets: ${r.unknown.join(', ')}`) + } + + if (r.missing_servers?.length) { + ctx.transcript.sys(`missing MCP servers: ${r.missing_servers.join(', ')}`) + } + + if (r.reset) { + ctx.transcript.sys('session reset. new tool configuration is active.') + } }) ) .catch(ctx.guardedErr) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index c8dfa587a9..224e50aaa1 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -2,52 +2,18 @@ import { imageTokenMeta, introMsg, toTranscriptMessages } from '../../../domain/ import type { BackgroundStartResponse, BtwStartResponse, - ConfigGetValueResponse, ConfigSetResponse, ImageAttachResponse, - InsightsResponse, - ReloadMcpResponse, SessionBranchResponse, SessionCompressResponse, - SessionHistoryResponse, - SessionSaveResponse, - SessionTitleResponse, - SessionUsageResponse, - SlashExecResponse, VoiceToggleResponse } from '../../../gatewayTypes.js' import { fmtK } from '../../../lib/text.js' -import type { PanelSection } from '../../../types.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' -const PAGE_TITLES: Record = { - debug: 'Debug', - fast: 'Fast', - platforms: 'Platforms', - snapshot: 'Snapshot' -} - -const passthrough = (name: string): SlashCommand => ({ - name, - run: (_arg, ctx, cmd) => - ctx.shared.showSlashOutput({ - command: cmd.slice(1), - flight: ctx.flight, - sid: ctx.sid, - title: PAGE_TITLES[name] ?? name - }) -}) - -const historyLabel = (role: string) => (role === 'assistant' ? 'Hermes' : role === 'user' ? 'You' : 'System') - export const sessionCommands: SlashCommand[] = [ - passthrough('debug'), - passthrough('fast'), - passthrough('platforms'), - passthrough('snapshot'), - { aliases: ['bg'], help: 'launch a background prompt', @@ -126,120 +92,33 @@ export const sessionCommands: SlashCommand[] = [ const meta = imageTokenMeta(r) ctx.transcript.sys(`attached image: ${r.name ?? ''}${meta ? ` · ${meta}` : ''}`) - r.remainder && ctx.composer.setInput(r.remainder) + + if (r.remainder) { + ctx.composer.setInput(r.remainder) + } }) ) } }, { - help: 'show provider details', - name: 'provider', - run: (_arg, ctx) => { - ctx.gateway.gw - .request('slash.exec', { command: 'provider', session_id: ctx.sid }) - .then(r => { - if (ctx.stale()) { - return - } - - ctx.transcript.page( - r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)', - 'Provider' - ) - }) - .catch(ctx.guardedErr) - } - }, - - { - help: 'switch theme skin', - name: 'skin', - run: (arg, ctx) => { - if (arg) { - return ctx.gateway - .rpc('config.set', { key: 'skin', value: arg }) - .then(ctx.guarded(r => r.value && ctx.transcript.sys(`skin → ${r.value}`))) - } - - ctx.gateway - .rpc('config.get', { key: 'skin' }) - .then(ctx.guarded(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`))) - } - }, - - { - help: 'toggle yolo mode', - name: 'yolo', - run: (_arg, ctx) => { - ctx.gateway - .rpc('config.set', { key: 'yolo', session_id: ctx.sid }) - .then(ctx.guarded(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`))) - } - }, - - { - help: 'inspect or set reasoning mode', - name: 'reasoning', - run: (arg, ctx) => { - if (!arg) { - return ctx.gateway - .rpc('config.get', { key: 'reasoning' }) - .then( - ctx.guarded( - r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) - ) - ) - } - - ctx.gateway - .rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }) - .then(ctx.guarded(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`))) - } - }, - - { - help: 'cycle verbose output', - name: 'verbose', - run: (arg, ctx) => { - ctx.gateway - .rpc('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' }) - .then(ctx.guarded(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`))) - } - }, - - { - help: 'personality panel or switch', + help: 'switch or reset personality (history reset on set)', name: 'personality', run: (arg, ctx) => { - if (arg) { - return ctx.gateway - .rpc('config.set', { key: 'personality', session_id: ctx.sid, value: arg }) - .then( - ctx.guarded(r => { - r.history_reset && ctx.session.resetVisibleHistory(r.info ?? null) - ctx.transcript.sys( - `personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}` - ) - ctx.local.maybeWarn(r) - }) - ) + if (!arg) { + return // py handles listing } - ctx.gateway.gw - .request('slash.exec', { command: 'personality', session_id: ctx.sid }) - .then(r => { - if (ctx.stale()) { - return + ctx.gateway.rpc('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then( + ctx.guarded(r => { + if (r.history_reset) { + ctx.session.resetVisibleHistory(r.info ?? null) } - ctx.transcript.panel('Personality', [ - { - text: r?.warning ? `warning: ${r.warning}\n\n${r?.output || '(no output)'}` : r?.output || '(no output)' - } - ]) + ctx.transcript.sys(`personality: ${r.value || 'default'}${r.history_reset ? ' · transcript cleared' : ''}`) + ctx.local.maybeWarn(r) }) - .catch(ctx.guardedErr) + ) } }, @@ -260,8 +139,13 @@ export const sessionCommands: SlashCommand[] = [ ctx.transcript.setHistoryItems(r.info ? [introMsg(r.info), ...rows] : rows) } - r.info && patchUiState({ info: r.info }) - r.usage && patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) + if (r.info) { + patchUiState({ info: r.info }) + } + + if (r.usage) { + patchUiState(state => ({ ...state, usage: { ...state.usage, ...r.usage } })) + } if ((r.removed ?? 0) <= 0) { return ctx.transcript.sys('nothing to compress') @@ -275,18 +159,6 @@ export const sessionCommands: SlashCommand[] = [ } }, - { - help: 'stop background processes', - name: 'stop', - run: (_arg, ctx) => { - ctx.gateway - .rpc<{ killed?: number }>('process.stop', {}) - .then( - ctx.guarded<{ killed?: number }>(r => ctx.transcript.sys(`killed ${r.killed ?? 0} registered process(es)`)) - ) - } - }, - { aliases: ['fork'], help: 'branch the session', @@ -310,121 +182,6 @@ export const sessionCommands: SlashCommand[] = [ } }, - { - aliases: ['reload_mcp'], - help: 'reload MCP servers', - name: 'reload-mcp', - run: (_arg, ctx) => - ctx.gateway - .rpc('reload.mcp', { session_id: ctx.sid }) - .then(ctx.guarded(() => ctx.transcript.sys('MCP reloaded'))) - }, - - { - help: 'inspect or set session title', - name: 'title', - run: (arg, ctx) => { - ctx.gateway - .rpc('session.title', { session_id: ctx.sid, ...(arg ? { title: arg } : {}) }) - .then(ctx.guarded(r => ctx.transcript.sys(`title: ${r.title || '(none)'}`))) - } - }, - - { - help: 'session usage', - name: 'usage', - run: (_arg, ctx) => { - ctx.gateway.rpc('session.usage', { session_id: ctx.sid }).then(r => { - if (ctx.stale()) { - return - } - - if (r) { - patchUiState({ - usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } - }) - } - - if (!r?.calls) { - return ctx.transcript.sys('no API calls yet') - } - - const f = (v: number | undefined) => (v ?? 0).toLocaleString() - const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null - - const rows: [string, string][] = [ - ['Model', r.model ?? ''], - ['Input tokens', f(r.input)], - ['Cache read tokens', f(r.cache_read)], - ['Cache write tokens', f(r.cache_write)], - ['Output tokens', f(r.output)], - ['Total tokens', f(r.total)], - ['API calls', f(r.calls)] - ] - - const sections: PanelSection[] = [{ rows }] - - cost && rows.push(['Cost', cost]) - r.context_max && - sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) - r.compressions && sections.push({ text: `Compressions: ${r.compressions}` }) - - ctx.transcript.panel('Usage', sections) - }) - } - }, - - { - help: 'save transcript to disk', - name: 'save', - run: (_arg, ctx) => { - ctx.gateway - .rpc('session.save', { session_id: ctx.sid }) - .then(ctx.guarded(r => r.file && ctx.transcript.sys(`saved: ${r.file}`))) - } - }, - - { - help: 'view message history', - name: 'history', - run: (_arg, ctx) => { - ctx.gateway.rpc('session.history', { session_id: ctx.sid }).then(r => { - if (ctx.stale() || typeof r?.count !== 'number') { - return - } - - if (!r.messages?.length) { - return ctx.transcript.sys(`${r.count} messages`) - } - - const body = r.messages - .map((m, i) => - m.role === 'tool' - ? `[Tool #${i + 1}] ${m.name || 'tool'} ${m.context || ''}`.trim() - : `[${historyLabel(m.role)} #${i + 1}] ${m.text || ''}`.trim() - ) - .join('\n\n') - - ctx.transcript.page(body, `History (${r.count})`) - }) - } - }, - - { - help: 'show current profile', - name: 'profile', - run: (_arg, ctx) => { - ctx.gateway.rpc('config.get', { key: 'profile' }).then( - ctx.guarded(r => { - const text = r.display || r.home || '(unknown profile)' - const lines = text.split('\n').filter(Boolean) - - lines.length <= 2 ? ctx.transcript.panel('Profile', [{ text }]) : ctx.transcript.page(text, 'Profile') - }) - ) - } - }, - { help: 'toggle voice input', name: 'voice', @@ -438,25 +195,5 @@ export const sessionCommands: SlashCommand[] = [ }) ) } - }, - - { - help: 'view usage insights', - name: 'insights', - run: (arg, ctx) => { - ctx.gateway.rpc('insights.get', { days: parseInt(arg) || 30 }).then( - ctx.guarded(r => - ctx.transcript.panel('Insights', [ - { - rows: [ - ['Period', `${r.days ?? 0} days`], - ['Sessions', `${r.sessions ?? 0}`], - ['Messages', `${r.messages ?? 0}`] - ] - } - ]) - ) - ) - } } ] diff --git a/ui-tui/src/app/slash/shared.ts b/ui-tui/src/app/slash/shared.ts deleted file mode 100644 index c6aba712b0..0000000000 --- a/ui-tui/src/app/slash/shared.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { MutableRefObject } from 'react' - -import type { SlashExecResponse } from '../../gatewayTypes.js' -import { rpcErrorMessage } from '../../lib/rpc.js' -import { getUiState } from '../uiStore.js' - -export interface SlashShared { - showSlashOutput: (opts: { command: string; flight: number; sid: null | string; title: string }) => void -} - -interface SlashSharedDeps { - gw: { request: (method: string, params?: Record) => Promise } - page: (text: string, title?: string) => void - slashFlightRef: MutableRefObject - sys: (text: string) => void -} - -export const createSlashShared = ({ gw, page, slashFlightRef, sys }: SlashSharedDeps): SlashShared => ({ - showSlashOutput: ({ command, flight, sid, title }) => { - const stale = () => flight !== slashFlightRef.current || getUiState().sid !== sid - - gw.request('slash.exec', { command, session_id: sid }) - .then(r => { - if (stale()) { - return - } - - const text = r?.warning ? `warning: ${r.warning}\n${r?.output || '(no output)'}` : r?.output || '(no output)' - - text.split('\n').filter(Boolean).length > 2 || text.length > 180 ? page(text, title) : sys(text) - }) - .catch((e: unknown) => { - if (!stale()) { - sys(`error: ${rpcErrorMessage(e)}`) - } - }) - } -}) diff --git a/ui-tui/src/app/slash/types.ts b/ui-tui/src/app/slash/types.ts index 4fa6e0b595..bbd187a23b 100644 --- a/ui-tui/src/app/slash/types.ts +++ b/ui-tui/src/app/slash/types.ts @@ -2,13 +2,10 @@ import type { MutableRefObject } from 'react' import type { SlashHandlerContext, UiState } from '../interfaces.js' -import type { SlashShared } from './shared.js' - export interface SlashRunCtx extends SlashHandlerContext { flight: number guarded: (fn: (r: T) => void) => (r: null | T) => void guardedErr: (e: unknown) => void - shared: SlashShared sid: null | string slashFlightRef: MutableRefObject stale: () => boolean diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index ee0e431230..1c991baeb4 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -110,11 +110,6 @@ export interface SessionUndoResponse { removed?: number } -export interface SessionHistoryResponse { - count?: number - messages?: GatewayTranscriptMessage[] -} - export interface SessionCompressResponse { info?: SessionInfo messages?: GatewayTranscriptMessage[] @@ -127,30 +122,6 @@ export interface SessionBranchResponse { title?: string } -export interface SessionTitleResponse { - title?: string -} - -export interface SessionSaveResponse { - file?: string -} - -export interface SessionUsageResponse { - cache_read?: number - cache_write?: number - calls?: number - compressions?: number - context_max?: number - context_percent?: number - context_used?: number - cost_status?: 'estimated' | 'exact' - cost_usd?: number - input?: number - model?: string - output?: number - total?: number -} - export interface SessionCloseResponse { ok?: boolean } @@ -240,34 +211,7 @@ export interface VoiceRecordResponse { text?: string } -// ── Tools / toolsets ───────────────────────────────────────────────── - -export interface ToolsetDetails { - description: string - enabled: boolean - name: string - tool_count: number - tools: string[] -} - -export interface ToolsListResponse { - toolsets?: ToolsetDetails[] -} - -export interface ToolSummary { - description: string - name: string -} - -export interface ToolsShowSection { - name: string - tools: ToolSummary[] -} - -export interface ToolsShowResponse { - sections?: ToolsShowSection[] - total?: number -} +// ── Tools (TS keeps configure since it resets local history) ───────── export interface ToolsConfigureResponse { changed?: string[] @@ -278,104 +222,7 @@ export interface ToolsConfigureResponse { unknown?: string[] } -export interface ToolsetsListResponse { - toolsets?: { - description: string - enabled: boolean - name: string - tool_count: number - }[] -} - -// ── Ops: rollback / browser / plugins / skills / agents / cron ─────── - -export interface RollbackCheckpoint { - hash?: string - message?: string -} - -export interface RollbackListResponse { - checkpoints?: RollbackCheckpoint[] -} - -export interface RollbackActionResponse { - diff?: string - message?: string - rendered?: string -} - -export interface BrowserManageResponse { - connected?: boolean - url?: string -} - -export interface PluginInfo { - enabled?: boolean - name?: string - version?: string -} - -export interface PluginsListResponse { - plugins?: PluginInfo[] -} - -export interface SkillsListResponse { - skills?: Record -} - -export interface SkillsBrowseItem { - description?: string - name?: string -} - -export interface SkillsBrowseResponse { - items?: SkillsBrowseItem[] - page?: number - total?: number - total_pages?: number -} - -export interface AgentProcess { - command?: string - session_id: string - status?: 'finished' | 'running' -} - -export interface AgentsListResponse { - processes?: AgentProcess[] -} - -export interface CronJob { - job_id?: string - name?: string - schedule?: string - state?: string -} - -export interface CronListResponse { - jobs?: CronJob[] -} - -export interface ConfigShowSection { - rows?: [string, string][] - title?: string -} - -export interface ConfigShowResponse { - sections?: ConfigShowSection[] -} - -// ── Insights / MCP ─────────────────────────────────────────────────── - -export interface InsightsResponse { - days?: number - messages?: number - sessions?: number -} - -export interface ReloadMcpResponse { - ok?: boolean -} +// ── Model picker ───────────────────────────────────────────────────── export interface ModelOptionProvider { is_current?: boolean @@ -392,6 +239,12 @@ export interface ModelOptionsResponse { providers?: ModelOptionProvider[] } +// ── MCP ────────────────────────────────────────────────────────────── + +export interface ReloadMcpResponse { + ok?: boolean +} + // ── Subagent events ────────────────────────────────────────────────── export interface SubagentEventPayload { From 18840bcff89ae688bdd7bf90da011c83396a85bc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 14:48:29 -0500 Subject: [PATCH 123/157] chore: uptick --- ui-tui/src/app/slash/commands/session.ts | 115 +++++++++++++++++++++++ ui-tui/src/components/appOverlays.tsx | 2 +- ui-tui/src/gatewayTypes.ts | 16 ++++ 3 files changed, 132 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 224e50aaa1..02a625604f 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -2,13 +2,16 @@ import { imageTokenMeta, introMsg, toTranscriptMessages } from '../../../domain/ import type { BackgroundStartResponse, BtwStartResponse, + ConfigGetValueResponse, ConfigSetResponse, ImageAttachResponse, SessionBranchResponse, SessionCompressResponse, + SessionUsageResponse, VoiceToggleResponse } from '../../../gatewayTypes.js' import { fmtK } from '../../../lib/text.js' +import type { PanelSection } from '../../../types.js' import { patchOverlayState } from '../../overlayStore.js' import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' @@ -195,5 +198,117 @@ export const sessionCommands: SlashCommand[] = [ }) ) } + }, + + // The four shims below call `config.set` directly because Python's `slash.exec` + // worker is a separate subprocess — it writes config but does NOT fire the + // live side-effects (`skin.changed` event, agent.reasoning_config, + // agent.verbose_logging, per-session yolo flip). Direct RPC does. + + { + help: 'switch theme skin (fires skin.changed)', + name: 'skin', + run: (arg, ctx) => { + if (!arg) { + return ctx.gateway + .rpc('config.get', { key: 'skin' }) + .then(ctx.guarded(r => ctx.transcript.sys(`skin: ${r.value || 'default'}`))) + } + + ctx.gateway + .rpc('config.set', { key: 'skin', value: arg }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`skin → ${r.value}`))) + } + }, + + { + help: 'toggle yolo mode (per-session approvals)', + name: 'yolo', + run: (_arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'yolo', session_id: ctx.sid }) + .then(ctx.guarded(r => ctx.transcript.sys(`yolo ${r.value === '1' ? 'on' : 'off'}`))) + } + }, + + { + help: 'inspect or set reasoning effort (updates live agent)', + name: 'reasoning', + run: (arg, ctx) => { + if (!arg) { + return ctx.gateway + .rpc('config.get', { key: 'reasoning' }) + .then( + ctx.guarded( + r => r.value && ctx.transcript.sys(`reasoning: ${r.value} · display ${r.display || 'hide'}`) + ) + ) + } + + ctx.gateway + .rpc('config.set', { key: 'reasoning', session_id: ctx.sid, value: arg }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`reasoning: ${r.value}`))) + } + }, + + { + help: 'cycle verbose tool-output mode (updates live agent)', + name: 'verbose', + run: (arg, ctx) => { + ctx.gateway + .rpc('config.set', { key: 'verbose', session_id: ctx.sid, value: arg || 'cycle' }) + .then(ctx.guarded(r => r.value && ctx.transcript.sys(`verbose: ${r.value}`))) + } + }, + + { + help: 'session usage (live counts — worker sees zeros)', + name: 'usage', + run: (_arg, ctx) => { + ctx.gateway.rpc('session.usage', { session_id: ctx.sid }).then(r => { + if (ctx.stale()) { + return + } + + if (r) { + patchUiState({ + usage: { calls: r.calls ?? 0, input: r.input ?? 0, output: r.output ?? 0, total: r.total ?? 0 } + }) + } + + if (!r?.calls) { + return ctx.transcript.sys('no API calls yet') + } + + const f = (v: number | undefined) => (v ?? 0).toLocaleString() + const cost = r.cost_usd != null ? `${r.cost_status === 'estimated' ? '~' : ''}$${r.cost_usd.toFixed(4)}` : null + + const rows: [string, string][] = [ + ['Model', r.model ?? ''], + ['Input tokens', f(r.input)], + ['Cache read tokens', f(r.cache_read)], + ['Cache write tokens', f(r.cache_write)], + ['Output tokens', f(r.output)], + ['Total tokens', f(r.total)], + ['API calls', f(r.calls)] + ] + + if (cost) { + rows.push(['Cost', cost]) + } + + const sections: PanelSection[] = [{ rows }] + + if (r.context_max) { + sections.push({ text: `Context: ${f(r.context_used)} / ${f(r.context_max)} (${r.context_percent}%)` }) + } + + if (r.compressions) { + sections.push({ text: `Compressions: ${r.compressions}` }) + } + + ctx.transcript.panel('Usage', sections) + }) + } } ] diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 75562eb9fd..5cdddd5046 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -146,7 +146,7 @@ export function AppOverlays({ key={`${start + i}:${item.text}:${item.display}:${item.meta ?? ''}`} width="100%" > - + {' '} {item.display} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 1c991baeb4..cd98dc9d47 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -110,6 +110,22 @@ export interface SessionUndoResponse { removed?: number } +export interface SessionUsageResponse { + cache_read?: number + cache_write?: number + calls?: number + compressions?: number + context_max?: number + context_percent?: number + context_used?: number + cost_status?: 'estimated' | 'exact' + cost_usd?: number + input?: number + model?: string + output?: number + total?: number +} + export interface SessionCompressResponse { info?: SessionInfo messages?: GatewayTranscriptMessage[] From c6ed61430a56a6d6c860afd7574ffc34479d9629 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 14:58:12 -0500 Subject: [PATCH 124/157] perf(tui): paint banner on first frame, don't wait on session.create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `historyItems` was seeded empty and the intro (with Banner + SessionPanel) was only pushed after Python's `session.create` returned — ~1.8s of agent + tools + MCP init with nothing on screen. Base CLI feels instant because it prints the banner as its first action. Seed `historyItems` with an info-less intro on mount. `appLayout` now renders the Banner unconditionally for `kind === 'intro'` and gates only the SessionPanel on `info` being present. Gateway.ready swaps the skin (~200ms) and session.info fills in the panel when the agent is ready. Net: first usable frame drops from ~2s to ~300ms (node + module graph + React mount). No behavior change — intro message is replaced in place by `introMsg(info)` when `newSession()` / `resumeById()` resolve. --- ui-tui/src/app/useMainApp.ts | 5 ++++- ui-tui/src/components/appLayout.tsx | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index f2827dfdfa..e6e8cfe1f4 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -93,7 +93,10 @@ export function useMainApp(gw: GatewayClient) { } }, [stdout]) - const [historyItems, setHistoryItems] = useState([]) + // Seed with an info-less intro so the banner paints on the first frame, + // before gateway.ready / session.create resolve. Replaced by + // `introMsg(info)` as soon as session.info arrives. + const [historyItems, setHistoryItems] = useState(() => [{ kind: 'intro', role: 'system', text: '' }]) const [lastUserMsg, setLastUserMsg] = useState('') const [stickyPrompt, setStickyPrompt] = useState('') const [catalog, setCatalog] = useState(null) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index d517fa7a74..48cf1c5b89 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -33,11 +33,11 @@ const TranscriptPane = memo(function TranscriptPane({ {visibleHistory.map(row => ( - {row.msg.kind === 'intro' && row.msg.info ? ( + {row.msg.kind === 'intro' ? ( - + {row.msg.info && } ) : row.msg.kind === 'panel' && row.msg.panelData ? ( From f3920fec0b184bdf511a7adf7a2b844e494d3f8a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 15:04:18 -0500 Subject: [PATCH 125/157] feat(tui): queue pre-session input, auto-flush when session lands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI is fully interactive from the first frame but `session.create` (agent + tools + MCP) takes ~2s. Plain-text messages typed before the session is live used to fail with "session not ready yet"; slash and shell commands worked but agent prompts were dropped. Now: - `dispatchSubmission` enqueues plain text when `sid` is null (slash/shell still short-circuit first) - `useMainApp` tracks sid transitions and kicks off one `sendQueued()` when the session first becomes ready; subsequent queued messages drain on `message.complete` as before - Fixed pre-existing double-Enter bug that dequeued without sid check User flow: type `hello` → shows in `queuedDisplay` preview → 2s later agent wakes → message auto-sends → reply streams. Zero wasted input. --- ui-tui/src/app/useMainApp.ts | 22 ++++++++++++++++++++++ ui-tui/src/app/useSubmission.ts | 22 ++++++++++++++-------- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index e6e8cfe1f4..c97fde79b4 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -372,6 +372,28 @@ export function useMainApp(gw: GatewayClient) { sys }) + // Flush any pre-session queued input once the session lands. + // Message.complete already drains subsequent items; this only kicks off the first. + const prevSidRef = useRef(null) + useEffect(() => { + const prev = prevSidRef.current + prevSidRef.current = ui.sid + + if (prev !== null || !ui.sid || ui.busy) { + return + } + + if (composerRefs.queueEditRef.current !== null) { + return + } + + const next = composerActions.dequeue() + + if (next) { + sendQueued(next) + } + }, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued]) + const { pagerPageSize } = useInputHandlers({ actions: { answerClarify, diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 4fc699676e..fee2dd7272 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -183,12 +183,7 @@ export function useSubmission(opts: UseSubmissionOptions) { return } - const live = getUiState() - - if (!live.sid) { - return sys('session not ready yet') - } - + // Slash + shell run regardless of session state (each handles its own sid needs). if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) composerActions.pushHistory(full) @@ -204,6 +199,17 @@ export function useSubmission(opts: UseSubmissionOptions) { return shellExec(full.slice(1).trim()) } + const live = getUiState() + + // No session yet — queue the text and let the ready-flush effect send it. + if (!live.sid) { + composerActions.pushHistory(full) + composerActions.enqueue(full) + composerActions.clearIn() + + return + } + const editIdx = composerRefs.queueEditRef.current composerActions.clearIn() @@ -269,10 +275,10 @@ export function useSubmission(opts: UseSubmissionOptions) { return turnController.interruptTurn({ appendMessage, gw, sid: live.sid, sys }) } - if (doubleTap && composerRefs.queueRef.current.length) { + if (doubleTap && live.sid && composerRefs.queueRef.current.length) { const next = composerActions.dequeue() - if (next && live.sid) { + if (next) { composerActions.setQueueEdit(null) dispatchSubmission(next) } From 2d693c865cf87ba02c1f10e48512b6e705a2a8af Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 15:21:49 -0500 Subject: [PATCH 126/157] perf(tui): spawn python gateway before loading @hermes/ink MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: entry.tsx imports @hermes/ink (394KB bundle) + App + GatewayClient in declaration order, then calls `gw.start()` at ~T=220ms. Python fork + server.py import starts then. After: only `GatewayClient` is statically imported (5ms, node builtins only). `gw.start()` fires at ~T=5ms. @hermes/ink + App load in parallel via `Promise.all(import(...))`. Python gets ~215ms of free runway to do its own module import before node even finishes loading. Net: session.info arrives ~150ms earlier in cold start. First React frame timing is unchanged (still ~240ms — still gated by ink+app imports). Removed a previously-tried warm-thread in server.py that pre-imported `run_agent` in the background. Measured variance showed occasional 5-10s outliers (GIL thrashing); median gain was <100ms. Not worth the non-determinism. --- ui-tui/src/entry.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 3ab4be96ba..abbcf7a4c8 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,7 +1,8 @@ #!/usr/bin/env node -import { render } from '@hermes/ink' - -import { App } from './app.js' +// Import order matters for cold start: `GatewayClient` has only node-builtin +// deps (<20ms), so spawning the python gateway before loading @hermes/ink +// + App (~200ms combined) gives python ~200ms of free parallel time to run +// its own module imports instead of starting those after node is done. import { GatewayClient } from './gatewayClient.js' if (!process.stdin.isTTY) { @@ -11,6 +12,7 @@ if (!process.stdin.isTTY) { const gw = new GatewayClient() gw.start() -render(, { - exitOnCtrlC: false -}) + +const [{ render }, { App }] = await Promise.all([import('@hermes/ink'), import('./app.js')]) + +render(, { exitOnCtrlC: false }) From a8e0a1148fe5197f83620a23dbc44f4279f8dce2 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 15:39:19 -0500 Subject: [PATCH 127/157] =?UTF-8?q?perf(tui):=20async=20session.create=20?= =?UTF-8?q?=E2=80=94=20sid=20live=20in=20~250ms=20instead=20of=20~1350ms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `session.create` blocked for ~1.2s on `_make_agent` (mostly `run_agent` transitive imports + AIAgent constructor). The UI waited through that whole window before sid became known and the banner/panel could render. Now `session.create` returns immediately with `{session_id, info: {model, cwd, tools:{}, skills:{}}}` and spawns a background thread that does the real `_make_agent` + `_init_session`. When the agent is live, the thread emits `session.info` with the full payload. Python side: - `_sessions[sid]` gets a placeholder dict with `agent=None` and a `threading.Event()` named `agent_ready` - `_wait_agent(session, rid, timeout=30)` blocks until the event is set (no-op when already set or absent, e.g. for `session.resume`) - `_sess()` now calls `_wait_agent` — so every handler routed through it (prompt.submit, session.usage, session.compress, session.branch, rollback.*, tools.configure, etc.) automatically holds until the agent is live, but only during the ~1s startup window - `terminal.resize` and `input.detect_drop` bypass the wait via direct dict lookup — they don't touch the agent and would otherwise block the first post-startup RPCs unnecessarily TS side: - `session.info` event handler now patches the intro message's `info` in-place so the seeded banner upgrades to the full session panel when the agent finishes initializing - `appLayout` gates `SessionPanel` on `info.version` being present (only set by `_session_info(agent)`, not by the partial payload from `session.create`) — so the panel only appears when real data arrives Net effect on cold start: T=~400ms banner paints (seeded intro) T=~245ms ui.sid set (session.create responds in ~1ms after ready) T=~1400ms session panel fills in (real session.info event) Pre-session keystrokes queue as before (already handled by the flush effect); `prompt.submit` will wait on `agent_ready` on the Python side when the flush tries to send before the agent is live. --- tui_gateway/server.py | 138 ++++++++++++++++---- ui-tui/src/app/createGatewayEventHandler.ts | 4 + ui-tui/src/components/appLayout.tsx | 2 +- 3 files changed, 120 insertions(+), 24 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8db15b6f67..04439c8406 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -201,8 +201,18 @@ def handle_request(req: dict) -> dict | None: def _sess(params, rid): + """Resolve session from params + block until its agent is ready. + + `session.create` builds the agent on a background thread (~500–1500ms + cold) so the placeholder session may exist before `session["agent"]` + is populated. Any handler that dereferences `session["agent"]` should + go through `_sess` — the wait is a free no-op when the event is + already set or absent (e.g. `session.resume` builds the agent inline). + """ s = _sessions.get(params.get("session_id") or "") - return (s, None) if s else (None, _err(rid, 4001, "session not found")) + if not s: + return None, _err(rid, 4001, "session not found") + return s, _wait_agent(s, rid) def _normalize_completion_path(path_part: str) -> str: @@ -1031,26 +1041,103 @@ def _history_to_messages(history: list[dict]) -> list[dict]: # ── Methods: session ───────────────────────────────────────────────── +def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: + """Block until the session's agent has been built, returning a JSON-RPC + error dict if initialization failed or timed out — or ``None`` when the + agent is live and ready for use. Cheap no-op when there is no + `agent_ready` event (already-ready sessions from `session.resume`, etc.). + """ + ready = session.get("agent_ready") + if ready is not None and not ready.is_set(): + if not ready.wait(timeout=timeout): + return _err(rid, 5032, "agent initialization timed out") + if session.get("agent_error"): + return _err(rid, 5032, session["agent_error"]) + return None + + @method("session.create") def _(rid, params: dict) -> dict: + """Non-blocking session creation. Returns the sid + minimal info right + away; the heavy agent init runs on a background thread and broadcasts a + `session.info` event when tools/skills are ready. Handlers that touch + `session["agent"]` must call `_wait_agent(session, rid)` first.""" sid = uuid.uuid4().hex[:8] key = _new_session_key() + cols = int(params.get("cols", 80)) os.environ["HERMES_INTERACTIVE"] = "1" - try: - tokens = _set_session_context(key) + + ready_event = threading.Event() + + # Placeholder session so subsequent RPCs find the sid and can wait on + # `agent_ready`. Fields mirror `_init_session`; anything derived from + # the agent is filled once the build thread completes. + _sessions[sid] = { + "agent": None, + "agent_error": None, + "agent_ready": ready_event, + "attached_images": [], + "cols": cols, + "edit_snapshots": {}, + "history": [], + "history_lock": threading.Lock(), + "history_version": 0, + "image_counter": 0, + "running": False, + "session_key": key, + "show_reasoning": _load_show_reasoning(), + "slash_worker": None, + "tool_progress_mode": _load_tool_progress_mode(), + "tool_started_at": {}, + } + + def _build() -> None: try: - agent = _make_agent(sid, key) + tokens = _set_session_context(key) + try: + agent = _make_agent(sid, key) + finally: + _clear_session_context(tokens) + + _get_db().create_session(key, source="tui", model=_resolve_model()) + _sessions[sid]["agent"] = agent + + try: + _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) + except Exception: + pass # slash.exec will surface the real failure + + try: + from tools.approval import register_gateway_notify, load_permanent_allowlist + register_gateway_notify(key, lambda data: _emit("approval.request", sid, data)) + load_permanent_allowlist() + except Exception: + pass + + _wire_callbacks(sid) + + info = _session_info(agent) + warn = _probe_credentials(agent) + if warn: + info["credential_warning"] = warn + _emit("session.info", sid, info) + except Exception as e: + _sessions[sid]["agent_error"] = str(e) + _emit("error", sid, {"message": f"agent init failed: {e}"}) finally: - _clear_session_context(tokens) - _get_db().create_session(key, source="tui", model=_resolve_model()) - _init_session(sid, key, agent, [], cols=int(params.get("cols", 80))) - except Exception as e: - return _err(rid, 5000, f"agent init failed: {e}") - info = _session_info(agent) - warn = _probe_credentials(agent) - if warn: - info["credential_warning"] = warn - return _ok(rid, {"session_id": sid, "info": info}) + ready_event.set() + + threading.Thread(target=_build, daemon=True).start() + + return _ok(rid, { + "session_id": sid, + "info": { + "model": _resolve_model(), + "tools": {}, + "skills": {}, + "cwd": os.getenv("TERMINAL_CWD", os.getcwd()), + }, + }) @method("session.list") @@ -1272,9 +1359,11 @@ def _(rid, params: dict) -> dict: @method("terminal.resize") def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err + # Direct dict lookup — no agent needed; skip `_sess`'s wait-for-agent + # gate so TUI's initial resize doesn't block on cold session.create. + session = _sessions.get(params.get("session_id") or "") + if not session: + return _err(rid, 4001, "session not found") session["cols"] = int(params.get("cols", 80)) return _ok(rid, {"cols": session["cols"]}) @@ -1284,9 +1373,9 @@ def _(rid, params: dict) -> dict: @method("prompt.submit") def _(rid, params: dict) -> dict: sid, text = params.get("session_id", ""), params.get("text", "") - session = _sessions.get(sid) - if not session: - return _err(rid, 4001, "session not found") + session, err = _sess(params, rid) + if err: + return err with session["history_lock"]: if session.get("running"): return _err(rid, 4009, "session busy") @@ -1450,9 +1539,12 @@ def _(rid, params: dict) -> dict: @method("input.detect_drop") def _(rid, params: dict) -> dict: - session, err = _sess(params, rid) - if err: - return err + # Pattern-matching on text — no agent needed. Skip `_sess`'s wait so + # the first post-startup message doesn't add the agent-build window + # on top of `prompt.submit`'s own wait. + session = _sessions.get(params.get("session_id") or "") + if not session: + return _err(rid, 4001, "session not found") try: from cli import _detect_file_drop diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index f2e08765e6..63a306b044 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -167,6 +167,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: info: ev.payload, usage: ev.payload.usage ? { ...state.usage, ...ev.payload.usage } : state.usage })) + // Agent init is async in session.create, so the intro message may + // have been seeded with partial info (just model/cwd). Upgrade it + // in-place when the real session.info lands. + setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info: ev.payload } : m))) return case 'thinking.delta': { diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 48cf1c5b89..e8ae95b1e7 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -37,7 +37,7 @@ const TranscriptPane = memo(function TranscriptPane({ - {row.msg.info && } + {row.msg.info?.version && } ) : row.msg.kind === 'panel' && row.msg.panelData ? ( From 04e36851b7f8554562bea071310ec675c5da571c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 15:41:44 -0500 Subject: [PATCH 128/157] =?UTF-8?q?feat(tui):=20honest=20status=20'startin?= =?UTF-8?q?g=20agent=E2=80=A6'=20until=20session.info=20arrives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-async-session.create, `session.create` returns in ~1ms with partial info and the real agent fires `session.info` ~1s later. Previously the status bar went straight to 'ready' right after the instant RPC return, which was misleading — `prompt.submit` would block server-side waiting for the agent to finish building. Now: - `newSession`: status = 'starting agent…' when info has no `version`, else 'ready' (covers the fast resume path too) - `session.info` event: flips status to 'ready' only if it was 'starting agent…', preserving running/interrupted/error states --- ui-tui/src/app/createGatewayEventHandler.ts | 3 +++ ui-tui/src/app/useSessionLifecycle.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 63a306b044..adb6d4f554 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -165,6 +165,9 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: patchUiState(state => ({ ...state, info: ev.payload, + // Flip from 'starting agent…' → 'ready' when the agent is live. + // Leave running/interrupted/error statuses alone. + status: state.status === 'starting agent…' ? 'ready' : state.status, usage: ev.payload.usage ? { ...state.usage, ...ev.payload.usage } : state.usage })) // Agent init is async in session.create, so the intro message may diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index bbde757cf2..9fed9fe984 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -104,7 +104,16 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { resetSession() setSessionStartedAt(Date.now()) - patchUiState({ info: r.info ?? null, sid: r.session_id, status: 'ready', usage: usageFrom(r.info ?? null) }) + // Python's `session.create` returns instantly with partial info (no `version` + // field); the `session.info` event will flip status to 'ready' once the + // agent is fully built (~1s later). Until then prompt.submit will block + // server-side on `_wait_agent`. + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: r.info?.version ? 'ready' : 'starting agent…', + usage: usageFrom(r.info ?? null) + }) if (r.info) { setHistoryItems([introMsg(r.info)]) From 9503896aa2923a510b24c9eca919b94813a7889d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 15:48:41 -0500 Subject: [PATCH 129/157] perf(tui): paint banner to stdout in ~2ms, before Ink loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic-importing @hermes/ink + App costs ~170ms on cold start — during that window the terminal was blank. Now `entry.tsx` writes a raw-ANSI banner to stdout immediately after the TTY check, using hardcoded DEFAULT_THEME colors. Ink's `` wipes the normal-screen buffer when it mounts, so the boot banner is replaced seamlessly by the real React render a moment later — no double-banner, no flash. T=2ms banner visible (vs. ~170ms before) T=~170ms React + Ink mounts T=~200ms alt screen takes over, Banner component repaints Palette drift between `bootBanner.ts` and the live theme is harmless — the live render overrides after ~200ms. Narrow terminals (cols < 98) fall back to the one-line "⚕ NOUS HERMES" marker. --- ui-tui/src/bootBanner.ts | 36 ++++++++++++++++++++++++++++++++++++ ui-tui/src/entry.tsx | 12 ++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 ui-tui/src/bootBanner.ts diff --git a/ui-tui/src/bootBanner.ts b/ui-tui/src/bootBanner.ts new file mode 100644 index 0000000000..2aac254a27 --- /dev/null +++ b/ui-tui/src/bootBanner.ts @@ -0,0 +1,36 @@ +// Prints the Hermes banner as raw ANSI to stdout before React/Ink load. +// Gives the user instant visual feedback during the ~170ms dynamic-import +// window; `` wipes the normal-screen buffer when Ink +// mounts, so there is no double-banner. +// +// Palette is hardcoded to match DEFAULT_THEME — drifting the theme's +// banner colors here is fine, Ink's real render takes over in ~200ms. + +const GOLD = '\x1b[38;2;255;215;0m' +const AMBER = '\x1b[38;2;255;191;0m' +const BRONZE = '\x1b[38;2;205;127;50m' +const DIM = '\x1b[38;2;184;134;11m' +const RESET = '\x1b[0m' + +const LOGO = [ + '██╗ ██╗███████╗██████╗ ███╗ ███╗███████╗███████╗ █████╗ ██████╗ ███████╗███╗ ██╗████████╗', + '██║ ██║██╔════╝██╔══██╗████╗ ████║██╔════╝██╔════╝ ██╔══██╗██╔════╝ ██╔════╝████╗ ██║╚══██╔══╝', + '███████║█████╗ ██████╔╝██╔████╔██║█████╗ ███████╗█████╗███████║██║ ███╗█████╗ ██╔██╗ ██║ ██║ ', + '██╔══██║██╔══╝ ██╔══██╗██║╚██╔╝██║██╔══╝ ╚════██║╚════╝██╔══██║██║ ██║██╔══╝ ██║╚██╗██║ ██║ ', + '██║ ██║███████╗██║ ██║██║ ╚═╝ ██║███████╗███████║ ██║ ██║╚██████╔╝███████╗██║ ╚████║ ██║ ', + '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ' +] + +const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] +const LOGO_WIDTH = 98 + +export function bootBanner(cols: number = process.stdout.columns || 80): string { + const lines = + cols >= LOGO_WIDTH + ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`) + : [`\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}`] + + return ( + '\n' + lines.join('\n') + '\n' + `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}\n\n` + ) +} diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index abbcf7a4c8..3c21de93e3 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,8 +1,10 @@ #!/usr/bin/env node -// Import order matters for cold start: `GatewayClient` has only node-builtin -// deps (<20ms), so spawning the python gateway before loading @hermes/ink -// + App (~200ms combined) gives python ~200ms of free parallel time to run -// its own module imports instead of starting those after node is done. +// Import order matters for cold start: `GatewayClient` + `bootBanner` have +// only node-builtin deps (<20ms), so we can paint the banner and spawn the +// python gateway before loading @hermes/ink + App (~170ms combined). +// `` wipes the normal-screen buffer on Ink mount, so the +// boot banner is replaced seamlessly by the real React render. +import { bootBanner } from './bootBanner.js' import { GatewayClient } from './gatewayClient.js' if (!process.stdin.isTTY) { @@ -10,6 +12,8 @@ if (!process.stdin.isTTY) { process.exit(0) } +process.stdout.write(bootBanner()) + const gw = new GatewayClient() gw.start() From 275256cdb42cf050b68e6e2a349887a38e3006d4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 15:50:28 -0500 Subject: [PATCH 130/157] feat(tui): uniform selection background instead of SGR inverse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection was falling back to SGR-7 inverse (fg ↔ bg per cell), which fragments over syntax-highlighted content — each amber/gold/dim/cornsilk fg turned into a different bg stripe, producing the staircase look. Now `useMainApp` calls `selection.setSelectionBgColor()` with a muted navy (`#3a3a55`) on theme change. `setSelectionBg` in screen.ts replaces just the bg cell-by-cell while preserving fg/bold/dim/italic, so the highlight is one solid color across the whole drag range and the text stays readable in its original color. Skins can override via `selection_bg` in their color map. --- ui-tui/src/app/useMainApp.ts | 7 +++++++ ui-tui/src/theme.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index c97fde79b4..d1ac42f5d0 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -130,6 +130,13 @@ export function useMainApp(gw: GatewayClient) { const hasSelection = useHasSelection() const selection = useSelection() + // Bind a uniform selection bg so drag-to-select shows one solid color + // across the whole range instead of SGR-inverse (which swaps each cell's + // fg → bg and fragments over amber/gold/dim text). Re-fires on skin swap. + useEffect(() => { + selection.setSelectionBgColor(ui.theme.color.selectionBg) + }, [selection, ui.theme.color.selectionBg]) + const composer = useComposerState({ gw, onClipboardPaste: quiet => clipboardPasteRef.current(quiet), diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index 48b7399723..d1f4aaa4b5 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -19,6 +19,8 @@ export interface ThemeColors { statusBad: string statusCritical: string + selectionBg: string + diffAdded: string diffRemoved: string diffAddedWord: string @@ -94,6 +96,11 @@ export const DEFAULT_THEME: Theme = { statusBad: '#FF8C00', statusCritical: '#FF6B6B', + // Uniform selection bg — matches the muted navy of the status bar so + // gold/amber fg stays readable and the highlight doesn't fragment per + // fg color the way SGR-inverse does. + selectionBg: '#3a3a55', + diffAdded: 'rgb(220,255,220)', diffRemoved: 'rgb(255,220,220)', diffAddedWord: 'rgb(36,138,61)', @@ -149,6 +156,8 @@ export function fromSkin( statusBad: d.color.statusBad, statusCritical: d.color.statusCritical, + selectionBg: c('selection_bg') ?? d.color.selectionBg, + diffAdded: d.color.diffAdded, diffRemoved: d.color.diffRemoved, diffAddedWord: d.color.diffAddedWord, From 727f0eaf74c07f6d88a3208443bd76b096438950 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 18:07:23 -0500 Subject: [PATCH 131/157] =?UTF-8?q?refactor(tui):=20clean=20up=20touched?= =?UTF-8?q?=20files=20=E2=80=94=20DRY,=20KISS,=20functional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python (tui_gateway/server.py): - hoist `_wait_agent` next to `_sess` so `_sess` no longer forward-refs - simplify `_wait_agent`: `ready.wait()` already returns True when set, no separate `.is_set()` check, collapse two returns into one expr - factor `_sess_nowait` for handlers that don't need the agent (currently `terminal.resize` + `input.detect_drop`) — DRY up the duplicated `_sessions.get` + "session not found" dance - inline `session = _sessions[sid]` in the session.create build thread so agent/worker writes don't re-look-up the dict each time - rename inline `ready_event` → `ready` (it's never ambiguous) TS: - `useSessionLifecycle.newSession`: hoist `r.info ?? null` into `info` so it's one lookup, drop ceremonial `{ … }` blocks around single-line bodies - `createGatewayEventHandler.session.info`: wrap the case in a block, hoist `ev.payload` into `info`, tighten comments - `useMainApp` flush effect: collapse two guard returns into one - `bootBanner.ts`: lift `TAGLINE` + `FALLBACK` to module constants, make `GRADIENT` readonly, one-liner return via template literal - `theme.ts`: group `selectionBg` inside the status* block (it's a UI surface bg, same family), trim the comment --- tui_gateway/server.py | 98 +++++++++++---------- ui-tui/src/app/createGatewayEventHandler.ts | 21 +++-- ui-tui/src/app/useMainApp.ts | 15 +--- ui-tui/src/app/useSessionLifecycle.ts | 27 +++--- ui-tui/src/bootBanner.ts | 28 +++--- ui-tui/src/theme.ts | 8 +- 6 files changed, 92 insertions(+), 105 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 04439c8406..0af85fd2ed 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -200,19 +200,38 @@ def handle_request(req: dict) -> dict | None: return fn(req.get("id"), req.get("params", {})) +def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: + """Block until the session's agent has been built. + + Returns a JSON-RPC error dict on failure/timeout, ``None`` when the + agent is live. Cheap no-op when ``agent_ready`` is absent (sessions + from `session.resume`, which builds inline) or already set. + """ + ready = session.get("agent_ready") + if ready is not None and not ready.wait(timeout=timeout): + return _err(rid, 5032, "agent initialization timed out") + err = session.get("agent_error") + return _err(rid, 5032, err) if err else None + + +def _sess_nowait(params, rid): + """Resolve session without gating on agent readiness — for handlers + that only touch placeholder fields (cols, attached_images) and + shouldn't eat the agent-build window on cold start.""" + s = _sessions.get(params.get("session_id") or "") + return (s, None) if s else (None, _err(rid, 4001, "session not found")) + + def _sess(params, rid): - """Resolve session from params + block until its agent is ready. + """Resolve session from params + block on ``_wait_agent``. `session.create` builds the agent on a background thread (~500–1500ms - cold) so the placeholder session may exist before `session["agent"]` - is populated. Any handler that dereferences `session["agent"]` should - go through `_sess` — the wait is a free no-op when the event is - already set or absent (e.g. `session.resume` builds the agent inline). + cold) so the placeholder session exists before ``session["agent"]`` + is populated. Routing every agent-touching RPC through `_sess` hides + that window — reads become free once the agent is live. """ - s = _sessions.get(params.get("session_id") or "") - if not s: - return None, _err(rid, 4001, "session not found") - return s, _wait_agent(s, rid) + s, err = _sess_nowait(params, rid) + return (None, err) if err else (s, _wait_agent(s, rid)) def _normalize_completion_path(path_part: str) -> str: @@ -1041,41 +1060,28 @@ def _history_to_messages(history: list[dict]) -> list[dict]: # ── Methods: session ───────────────────────────────────────────────── -def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: - """Block until the session's agent has been built, returning a JSON-RPC - error dict if initialization failed or timed out — or ``None`` when the - agent is live and ready for use. Cheap no-op when there is no - `agent_ready` event (already-ready sessions from `session.resume`, etc.). - """ - ready = session.get("agent_ready") - if ready is not None and not ready.is_set(): - if not ready.wait(timeout=timeout): - return _err(rid, 5032, "agent initialization timed out") - if session.get("agent_error"): - return _err(rid, 5032, session["agent_error"]) - return None - - @method("session.create") def _(rid, params: dict) -> dict: - """Non-blocking session creation. Returns the sid + minimal info right - away; the heavy agent init runs on a background thread and broadcasts a - `session.info` event when tools/skills are ready. Handlers that touch - `session["agent"]` must call `_wait_agent(session, rid)` first.""" + """Non-blocking session creation. + + Returns the sid + minimal info right away; the heavy agent init runs + on a background thread and broadcasts `session.info` when tools and + skills are wired. Handlers that touch ``session["agent"]`` go through + `_sess`, which gates on the `agent_ready` event. + """ sid = uuid.uuid4().hex[:8] key = _new_session_key() cols = int(params.get("cols", 80)) os.environ["HERMES_INTERACTIVE"] = "1" - ready_event = threading.Event() + ready = threading.Event() # Placeholder session so subsequent RPCs find the sid and can wait on - # `agent_ready`. Fields mirror `_init_session`; anything derived from - # the agent is filled once the build thread completes. + # `agent_ready`; anything derived from the agent is filled in by `_build`. _sessions[sid] = { "agent": None, "agent_error": None, - "agent_ready": ready_event, + "agent_ready": ready, "attached_images": [], "cols": cols, "edit_snapshots": {}, @@ -1092,6 +1098,7 @@ def _(rid, params: dict) -> dict: } def _build() -> None: + session = _sessions[sid] try: tokens = _set_session_context(key) try: @@ -1100,10 +1107,10 @@ def _(rid, params: dict) -> dict: _clear_session_context(tokens) _get_db().create_session(key, source="tui", model=_resolve_model()) - _sessions[sid]["agent"] = agent + session["agent"] = agent try: - _sessions[sid]["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) + session["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) except Exception: pass # slash.exec will surface the real failure @@ -1122,10 +1129,10 @@ def _(rid, params: dict) -> dict: info["credential_warning"] = warn _emit("session.info", sid, info) except Exception as e: - _sessions[sid]["agent_error"] = str(e) + session["agent_error"] = str(e) _emit("error", sid, {"message": f"agent init failed: {e}"}) finally: - ready_event.set() + ready.set() threading.Thread(target=_build, daemon=True).start() @@ -1359,11 +1366,9 @@ def _(rid, params: dict) -> dict: @method("terminal.resize") def _(rid, params: dict) -> dict: - # Direct dict lookup — no agent needed; skip `_sess`'s wait-for-agent - # gate so TUI's initial resize doesn't block on cold session.create. - session = _sessions.get(params.get("session_id") or "") - if not session: - return _err(rid, 4001, "session not found") + session, err = _sess_nowait(params, rid) + if err: + return err session["cols"] = int(params.get("cols", 80)) return _ok(rid, {"cols": session["cols"]}) @@ -1539,12 +1544,11 @@ def _(rid, params: dict) -> dict: @method("input.detect_drop") def _(rid, params: dict) -> dict: - # Pattern-matching on text — no agent needed. Skip `_sess`'s wait so - # the first post-startup message doesn't add the agent-build window - # on top of `prompt.submit`'s own wait. - session = _sessions.get(params.get("session_id") or "") - if not session: - return _err(rid, 4001, "session not found") + # Pure text pattern-matching — bypass the agent-ready gate so the first + # post-startup send doesn't stack the wait on top of `prompt.submit`'s. + session, err = _sess_nowait(params, rid) + if err: + return err try: from cli import _detect_file_drop diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index adb6d4f554..105a90707a 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -161,21 +161,24 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return - case 'session.info': + case 'session.info': { + const info = ev.payload + patchUiState(state => ({ ...state, - info: ev.payload, - // Flip from 'starting agent…' → 'ready' when the agent is live. - // Leave running/interrupted/error statuses alone. + info, + // agent just came online → flip the 'starting agent…' placeholder. + // leave running/interrupted/error statuses alone. status: state.status === 'starting agent…' ? 'ready' : state.status, - usage: ev.payload.usage ? { ...state.usage, ...ev.payload.usage } : state.usage + usage: info.usage ? { ...state.usage, ...info.usage } : state.usage })) - // Agent init is async in session.create, so the intro message may - // have been seeded with partial info (just model/cwd). Upgrade it - // in-place when the real session.info lands. - setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info: ev.payload } : m))) + + // upgrade the seeded/partial intro row in-place with the real info + setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m))) return + } + case 'thinking.delta': { const text = ev.payload?.text diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index d1ac42f5d0..1c69c78a0d 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -379,26 +379,19 @@ export function useMainApp(gw: GatewayClient) { sys }) - // Flush any pre-session queued input once the session lands. - // Message.complete already drains subsequent items; this only kicks off the first. + // Flush any pre-session queued input the moment the session lands. + // `message.complete` drains the rest; this just kicks off the first send. const prevSidRef = useRef(null) useEffect(() => { const prev = prevSidRef.current prevSidRef.current = ui.sid - if (prev !== null || !ui.sid || ui.busy) { - return - } - - if (composerRefs.queueEditRef.current !== null) { + if (prev !== null || !ui.sid || ui.busy || composerRefs.queueEditRef.current !== null) { return } const next = composerActions.dequeue() - - if (next) { - sendQueued(next) - } + if (next) sendQueued(next) }, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued]) const { pagerPageSize } = useInputHandlers({ diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 9fed9fe984..4a496f5f76 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -102,30 +102,25 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { return patchUiState({ status: 'ready' }) } + const info = r.info ?? null + resetSession() setSessionStartedAt(Date.now()) - // Python's `session.create` returns instantly with partial info (no `version` - // field); the `session.info` event will flip status to 'ready' once the - // agent is fully built (~1s later). Until then prompt.submit will block - // server-side on `_wait_agent`. + + // session.create returns instantly with partial info (no `version`); + // the `session.info` event flips status to 'ready' once the agent is live. patchUiState({ - info: r.info ?? null, + info, sid: r.session_id, - status: r.info?.version ? 'ready' : 'starting agent…', - usage: usageFrom(r.info ?? null) + status: info?.version ? 'ready' : 'starting agent…', + usage: usageFrom(info) }) - if (r.info) { - setHistoryItems([introMsg(r.info)]) - } + if (info) setHistoryItems([introMsg(info)]) - if (r.info?.credential_warning) { - sys(`warning: ${r.info.credential_warning}`) - } + if (info?.credential_warning) sys(`warning: ${info.credential_warning}`) - if (msg) { - sys(msg) - } + if (msg) sys(msg) }, [closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) diff --git a/ui-tui/src/bootBanner.ts b/ui-tui/src/bootBanner.ts index 2aac254a27..35fd3ce40a 100644 --- a/ui-tui/src/bootBanner.ts +++ b/ui-tui/src/bootBanner.ts @@ -1,10 +1,7 @@ -// Prints the Hermes banner as raw ANSI to stdout before React/Ink load. -// Gives the user instant visual feedback during the ~170ms dynamic-import -// window; `` wipes the normal-screen buffer when Ink -// mounts, so there is no double-banner. -// -// Palette is hardcoded to match DEFAULT_THEME — drifting the theme's -// banner colors here is fine, Ink's real render takes over in ~200ms. +// Raw-ANSI banner painted to stdout before React/Ink load, giving the user +// instant visual feedback during the ~170ms dynamic-import window. +// `` wipes the normal-screen buffer when Ink mounts, so +// there's no double-banner and palette drift vs. DEFAULT_THEME is harmless. const GOLD = '\x1b[38;2;255;215;0m' const AMBER = '\x1b[38;2;255;191;0m' @@ -21,16 +18,15 @@ const LOGO = [ '╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═══╝ ╚═╝ ' ] -const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] +const GRADIENT = [GOLD, GOLD, AMBER, AMBER, BRONZE, BRONZE] as const const LOGO_WIDTH = 98 -export function bootBanner(cols: number = process.stdout.columns || 80): string { - const lines = - cols >= LOGO_WIDTH - ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`) - : [`\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}`] +const TAGLINE = `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}` +const FALLBACK = `\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}` - return ( - '\n' + lines.join('\n') + '\n' + `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET}\n\n` - ) +export function bootBanner(cols: number = process.stdout.columns || 80): string { + const body = + cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK + + return `\n${body}\n${TAGLINE}\n\n` } diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index d1f4aaa4b5..cf85786c74 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -18,7 +18,6 @@ export interface ThemeColors { statusWarn: string statusBad: string statusCritical: string - selectionBg: string diffAdded: string @@ -95,10 +94,8 @@ export const DEFAULT_THEME: Theme = { statusWarn: '#FFD700', statusBad: '#FF8C00', statusCritical: '#FF6B6B', - - // Uniform selection bg — matches the muted navy of the status bar so - // gold/amber fg stays readable and the highlight doesn't fragment per - // fg color the way SGR-inverse does. + // muted navy — sits under gold/amber fg without fighting it, swaps + // cleanly with SGR-inverse that fragmented per fg color selectionBg: '#3a3a55', diffAdded: 'rgb(220,255,220)', @@ -155,7 +152,6 @@ export function fromSkin( statusWarn: c('ui_warn') ?? d.color.statusWarn, statusBad: d.color.statusBad, statusCritical: d.color.statusCritical, - selectionBg: c('selection_bg') ?? d.color.selectionBg, diffAdded: d.color.diffAdded, From dd2ec6bfa0b61322a47f4e4a7715a9c70f6807d8 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 18:57:56 -0500 Subject: [PATCH 132/157] chore: uptick --- ...26-04-01-ink-gateway-tui-migration-plan.md | 1242 ++--------------- 1 file changed, 90 insertions(+), 1152 deletions(-) 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 index 5692c5e7a8..9050d2c0c6 100644 --- a/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md +++ b/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md @@ -1,1170 +1,108 @@ -# TUI Refactor: Current to Ideal +# Ink Gateway TUI Migration — Post-mortem -Date: 2026-04-01 +Planned: 2026-04-01 · Delivered: 2026-04 · Status: shipped, PT path still present -## Scope +## What Shipped -- same repo refactor -- keep Python runtime -- replace PT-based interactive shell -- add Ink UI through a local gateway +Three layers, same repo, Python runtime unchanged. -## 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 +``` +ui-tui (Node/TS) ──stdio JSON-RPC──▶ tui_gateway (Py) ──▶ AIAgent (run_agent.py) ``` -## 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" - } -} +### Backend — `tui_gateway/` + +``` +tui_gateway/ +├── entry.py # subprocess entrypoint, stdio read/write loop +├── server.py # everything: sessions dict, @method handlers, _emit +├── render.py # stream renderer, diff rendering, message rendering +├── slash_worker.py # subprocess that runs hermes_cli slash commands +└── __init__.py ``` -Event notification: +`server.py` owns the full runtime-control surface: session store (`_sessions: dict[str, dict]`), method registry (`@method("…")` decorator), event emitter (`_emit`), agent lifecycle (`_make_agent`, `_init_session`, `_wire_callbacks`), approval/sudo/clarify round-trips, and JSON-RPC dispatch. -```json -{ - "jsonrpc": "2.0", - "method": "event", - "params": { - "type": "message.delta", - "session_id": "sess-1", - "payload": { - "text": "hi" - } - } -} +Protocol methods (`@method(...)` in `server.py`): + +- session: `session.{create, resume, list, close, interrupt, usage, history, compress, branch, title, save, undo}` +- prompt: `prompt.{submit, background, btw}` +- tools: `tools.{list, show, configure}` +- slash: `slash.exec`, `command.{dispatch, resolve}`, `commands.catalog`, `complete.{path, slash}` +- approvals: `approval.respond`, `sudo.respond`, `clarify.respond`, `secret.respond` +- config/state: `config.{get, set, show}`, `model.options`, `reload.mcp` +- ops: `shell.exec`, `cli.exec`, `terminal.resize`, `input.detect_drop`, `clipboard.paste`, `paste.collapse`, `image.attach`, `process.stop` +- misc: `agents.list`, `skills.manage`, `plugins.list`, `cron.manage`, `insights.get`, `rollback.{list, diff, restore}`, `browser.manage` + +Protocol events (`_emit(…)` → handled in `ui-tui/src/app/createGatewayEventHandler.ts`): + +- lifecycle: `gateway.{ready, stderr}`, `session.info`, `skin.changed` +- stream: `message.{start, delta, complete}`, `thinking.delta`, `reasoning.{delta, available}`, `status.update` +- tools: `tool.{start, progress, complete, generating}`, `subagent.{start, thinking, tool, progress, complete}` +- interactive: `approval.request`, `sudo.request`, `clarify.request`, `secret.request` +- async: `background.complete`, `btw.complete`, `error` + +### Frontend — `ui-tui/src/` + +``` +src/ +├── entry.tsx # node bootstrap: bootBanner → spawn python → dynamic-import Ink → render() +├── app.tsx # wraps +├── bootBanner.ts # raw-ANSI banner to stdout in ~2ms, pre-React +├── gatewayClient.ts # JSON-RPC client over child_process stdio +├── gatewayTypes.ts # typed RPC responses + GatewayEvent union +├── theme.ts # DEFAULT_THEME + fromSkin +│ +├── app/ # hooks + stores — the orchestration layer +│ ├── uiStore.ts # nanostore: sid, info, busy, usage, theme, status… +│ ├── turnStore.ts # nanostore: per-turn activity / reasoning / tools +│ ├── turnController.ts # imperative singleton for stream-time operations +│ ├── overlayStore.ts # nanostore: modal/overlay state +│ ├── useMainApp.ts # top-level composition hook +│ ├── useSessionLifecycle.ts # session.create/resume/close/reset +│ ├── useSubmission.ts # shell/slash/prompt dispatch + interpolation +│ ├── useConfigSync.ts # config.get + mtime poll +│ ├── useComposerState.ts # input buffer, paste snippets, editor mode +│ ├── useInputHandlers.ts # key bindings +│ ├── createGatewayEventHandler.ts # event-stream dispatcher +│ ├── createSlashHandler.ts # slash command router (registry + python fallback) +│ └── slash/commands/ # core.ts, ops.ts, session.ts — TS-owned slash commands +│ +├── components/ # AppLayout, AppChrome, AppOverlays, MessageLine, Thinking, Markdown, pickers, prompts, Banner, SessionPanel +├── config/ # env, limits, timing constants +├── content/ # charms, faces, fortunes, hotkeys, placeholders, verbs +├── domain/ # details, messages, paths, roles, slash, usage, viewport +├── protocol/ # interpolation, paste regex +├── hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory +└── lib/ # history, messages, osc52, rpc, text ``` -Error: +### CLI entry points — `hermes_cli/main.py` -```json -{ - "jsonrpc": "2.0", - "id": "req-123", - "error": { - "code": 4001, - "message": "session not found" - } -} -``` +- `hermes --tui` → `node dist/entry.js` (auto-builds when `.ts`/`.tsx` newer than `dist/entry.js`) +- `hermes --tui --dev` → `tsx src/entry.tsx` (skip build) +- `HERMES_TUI_DIR=…` → external prebuilt dist (nix, distro packaging) -Protocol rules: +## Diverged From Original Plan -- 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` +| Plan | Reality | Why | +|---|---|---| +| `tui_gateway/{controller,session_state,events,protocol}.py` | all collapsed into `server.py` | no second consumer ever emerged, keeping one file cheaper than four | +| `ui-tui/src/main.tsx` | split into `entry.tsx` (bootstrap) + `app.tsx` (shell) | boot banner + early python spawn wanted a pre-React moment | +| `ui-tui/src/state/store.ts` | three nanostores (`uiStore`, `turnStore`, `overlayStore`) | separate lifetimes: ui persists, turn resets per reply, overlay is modal | +| `approval.requested` / `sudo.requested` / `clarify.requested` | `*.request` (no `-ed`) | cosmetic | +| `session.cancel` | dropped | `session.interrupt` covers it | +| `HERMES_EXPERIMENTAL_TUI=1`, `display.experimental_tui: true`, `/tui on/off/status` | none shipped | `--tui` went from opt-in to first-class without an experimental phase | -## Acceptance Checks Per Phase +## Post-migration Additions (not in original plan) -Each phase should ship with explicit checks: +- **Async `session.create`** — returns sid in ~1ms, agent builds on a background thread, `session.info` broadcasts when ready; `_wait_agent()` gates every agent-touching handler via `_sess` +- **`bootBanner`** — raw-ANSI logo painted to stdout at T≈2ms, before Ink loads; `` wipes it seamlessly when React mounts +- **Selection uniform bg** — `theme.color.selectionBg` wired via `useSelection().setSelectionBgColor`; replaces SGR-inverse per-cell swap that fragmented over amber/gold fg +- **Slash command registry** — TS-owned commands in `app/slash/commands/{core,ops,session}.ts`, everything else falls through to `slash.exec` (python worker) +- **Turn store + controller split** — imperative singleton (`turnController`) holds refs/timers, nanostore (`turnStore`) holds render-visible state -- `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 +## What's Still Open -## 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 +- **PT path not deleted.** `cli.py` still has ~80 `prompt_toolkit` references; classic REPL is still the default when `--tui` is absent. The original plan's "Cut 4 · PT path removal later" hasn't happened. +- **No config-file opt-in.** `HERMES_EXPERIMENTAL_TUI` and `display.experimental_tui` were never built; only the CLI flag exists. Fine for now — if we want "default to TUI", a single line in `main.py` flips it. From ca30803d890d640e7f671db94ee3e0c41d689347 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 19:14:05 -0500 Subject: [PATCH 133/157] chore(tui): strip noise comments --- tests/hermes_cli/test_tui_resume_flow.py | 5 ---- tests/test_tui_gateway_server.py | 2 -- tui_gateway/server.py | 29 +-------------------- ui-tui/src/app/createGatewayEventHandler.ts | 3 --- ui-tui/src/app/useMainApp.ts | 8 ------ ui-tui/src/app/useSessionLifecycle.ts | 2 -- ui-tui/src/bootBanner.ts | 5 ---- ui-tui/src/entry.tsx | 6 +---- ui-tui/src/theme.ts | 2 -- 9 files changed, 2 insertions(+), 60 deletions(-) diff --git a/tests/hermes_cli/test_tui_resume_flow.py b/tests/hermes_cli/test_tui_resume_flow.py index 7d0d0d1157..c7e551ea1c 100644 --- a/tests/hermes_cli/test_tui_resume_flow.py +++ b/tests/hermes_cli/test_tui_resume_flow.py @@ -17,11 +17,6 @@ def _args(**overrides): @pytest.fixture def main_mod(monkeypatch): - """cmd_chat entry with the first-run provider-gate stubbed past. - - `cmd_chat` now early-exits when no API key is configured (post-merge); - these tests exercise the post-config routing so we fake the check out. - """ import hermes_cli.main as mod monkeypatch.setattr(mod, "_has_any_provider_configured", lambda: True) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 18631d0ee3..9c6305dedc 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -231,8 +231,6 @@ def test_config_set_personality_resets_history_and_returns_info(monkeypatch): monkeypatch.setattr(server, "_session_info", lambda agent: {"model": getattr(agent, "model", "?")}) monkeypatch.setattr(server, "_restart_slash_worker", lambda session: None) monkeypatch.setattr(server, "_emit", lambda *args: emits.append(args)) - # _write_config_key writes to ~/.hermes/config.yaml — races with other - # xdist workers that touch the same file. Stub it out. monkeypatch.setattr(server, "_write_config_key", lambda path, value: None) resp = server.handle_request( diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 0af85fd2ed..262f9abb0d 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -201,12 +201,6 @@ def handle_request(req: dict) -> dict | None: def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: - """Block until the session's agent has been built. - - Returns a JSON-RPC error dict on failure/timeout, ``None`` when the - agent is live. Cheap no-op when ``agent_ready`` is absent (sessions - from `session.resume`, which builds inline) or already set. - """ ready = session.get("agent_ready") if ready is not None and not ready.wait(timeout=timeout): return _err(rid, 5032, "agent initialization timed out") @@ -215,21 +209,11 @@ def _wait_agent(session: dict, rid: str, timeout: float = 30.0) -> dict | None: def _sess_nowait(params, rid): - """Resolve session without gating on agent readiness — for handlers - that only touch placeholder fields (cols, attached_images) and - shouldn't eat the agent-build window on cold start.""" s = _sessions.get(params.get("session_id") or "") return (s, None) if s else (None, _err(rid, 4001, "session not found")) def _sess(params, rid): - """Resolve session from params + block on ``_wait_agent``. - - `session.create` builds the agent on a background thread (~500–1500ms - cold) so the placeholder session exists before ``session["agent"]`` - is populated. Routing every agent-touching RPC through `_sess` hides - that window — reads become free once the agent is live. - """ s, err = _sess_nowait(params, rid) return (None, err) if err else (s, _wait_agent(s, rid)) @@ -1062,13 +1046,6 @@ def _history_to_messages(history: list[dict]) -> list[dict]: @method("session.create") def _(rid, params: dict) -> dict: - """Non-blocking session creation. - - Returns the sid + minimal info right away; the heavy agent init runs - on a background thread and broadcasts `session.info` when tools and - skills are wired. Handlers that touch ``session["agent"]`` go through - `_sess`, which gates on the `agent_ready` event. - """ sid = uuid.uuid4().hex[:8] key = _new_session_key() cols = int(params.get("cols", 80)) @@ -1076,8 +1053,6 @@ def _(rid, params: dict) -> dict: ready = threading.Event() - # Placeholder session so subsequent RPCs find the sid and can wait on - # `agent_ready`; anything derived from the agent is filled in by `_build`. _sessions[sid] = { "agent": None, "agent_error": None, @@ -1112,7 +1087,7 @@ def _(rid, params: dict) -> dict: try: session["slash_worker"] = _SlashWorker(key, getattr(agent, "model", _resolve_model())) except Exception: - pass # slash.exec will surface the real failure + pass try: from tools.approval import register_gateway_notify, load_permanent_allowlist @@ -1544,8 +1519,6 @@ def _(rid, params: dict) -> dict: @method("input.detect_drop") def _(rid, params: dict) -> dict: - # Pure text pattern-matching — bypass the agent-ready gate so the first - # post-startup send doesn't stack the wait on top of `prompt.submit`'s. session, err = _sess_nowait(params, rid) if err: return err diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 105a90707a..02057e807e 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -167,13 +167,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: patchUiState(state => ({ ...state, info, - // agent just came online → flip the 'starting agent…' placeholder. - // leave running/interrupted/error statuses alone. status: state.status === 'starting agent…' ? 'ready' : state.status, usage: info.usage ? { ...state.usage, ...info.usage } : state.usage })) - // upgrade the seeded/partial intro row in-place with the real info setHistoryItems(prev => prev.map(m => (m.kind === 'intro' ? { ...m, info } : m))) return diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 1c69c78a0d..8f4509594e 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -93,9 +93,6 @@ export function useMainApp(gw: GatewayClient) { } }, [stdout]) - // Seed with an info-less intro so the banner paints on the first frame, - // before gateway.ready / session.create resolve. Replaced by - // `introMsg(info)` as soon as session.info arrives. const [historyItems, setHistoryItems] = useState(() => [{ kind: 'intro', role: 'system', text: '' }]) const [lastUserMsg, setLastUserMsg] = useState('') const [stickyPrompt, setStickyPrompt] = useState('') @@ -130,9 +127,6 @@ export function useMainApp(gw: GatewayClient) { const hasSelection = useHasSelection() const selection = useSelection() - // Bind a uniform selection bg so drag-to-select shows one solid color - // across the whole range instead of SGR-inverse (which swaps each cell's - // fg → bg and fragments over amber/gold/dim text). Re-fires on skin swap. useEffect(() => { selection.setSelectionBgColor(ui.theme.color.selectionBg) }, [selection, ui.theme.color.selectionBg]) @@ -379,8 +373,6 @@ export function useMainApp(gw: GatewayClient) { sys }) - // Flush any pre-session queued input the moment the session lands. - // `message.complete` drains the rest; this just kicks off the first send. const prevSidRef = useRef(null) useEffect(() => { const prev = prevSidRef.current diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 4a496f5f76..3319f7a079 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -107,8 +107,6 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { resetSession() setSessionStartedAt(Date.now()) - // session.create returns instantly with partial info (no `version`); - // the `session.info` event flips status to 'ready' once the agent is live. patchUiState({ info, sid: r.session_id, diff --git a/ui-tui/src/bootBanner.ts b/ui-tui/src/bootBanner.ts index 35fd3ce40a..3cbd0a0895 100644 --- a/ui-tui/src/bootBanner.ts +++ b/ui-tui/src/bootBanner.ts @@ -1,8 +1,3 @@ -// Raw-ANSI banner painted to stdout before React/Ink load, giving the user -// instant visual feedback during the ~170ms dynamic-import window. -// `` wipes the normal-screen buffer when Ink mounts, so -// there's no double-banner and palette drift vs. DEFAULT_THEME is harmless. - const GOLD = '\x1b[38;2;255;215;0m' const AMBER = '\x1b[38;2;255;191;0m' const BRONZE = '\x1b[38;2;205;127;50m' diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 3c21de93e3..e0a4379342 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -1,9 +1,5 @@ #!/usr/bin/env node -// Import order matters for cold start: `GatewayClient` + `bootBanner` have -// only node-builtin deps (<20ms), so we can paint the banner and spawn the -// python gateway before loading @hermes/ink + App (~170ms combined). -// `` wipes the normal-screen buffer on Ink mount, so the -// boot banner is replaced seamlessly by the real React render. +// Order matters: paint banner + spawn python before loading @hermes/ink. import { bootBanner } from './bootBanner.js' import { GatewayClient } from './gatewayClient.js' diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index cf85786c74..ddd722e538 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -94,8 +94,6 @@ export const DEFAULT_THEME: Theme = { statusWarn: '#FFD700', statusBad: '#FF8C00', statusCritical: '#FF6B6B', - // muted navy — sits under gold/amber fg without fighting it, swaps - // cleanly with SGR-inverse that fragmented per fg color selectionBg: '#3a3a55', diffAdded: 'rgb(220,255,220)', From 2812bfe5b9d799a107b019e0245ef5b1299e9041 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 19:29:18 -0500 Subject: [PATCH 134/157] docs(tui): add Ink TUI user guide + cross-link from CLI docs New primary guide at `user-guide/tui.md` covering launch, requirements, keybindings, slash commands, status line, configuration, sessions, and the revert path. Matches the voice of `user-guide/cli.md`. Cross-links: - `user-guide/cli.md`: tip callout pointing readers at the Ink TUI - `getting-started/quickstart.md`: shows both `hermes` and `hermes --tui` under "Start Chatting" so first-run users know they have the choice - `reference/environment-variables.md`: new "Interface" section with `HERMES_TUI` and `HERMES_TUI_DIR` - `reference/cli-commands.md`: `--tui` and `--dev` added to global options Sidebar: `user-guide/tui` slotted right after `user-guide/cli`. --- website/docs/getting-started/quickstart.md | 7 +- website/docs/reference/cli-commands.md | 2 + .../docs/reference/environment-variables.md | 7 + website/docs/user-guide/cli.md | 4 + website/docs/user-guide/tui.md | 142 ++++++++++++++++++ website/sidebars.ts | 1 + 6 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 website/docs/user-guide/tui.md diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 880c01cb2a..997b0acf35 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -77,11 +77,16 @@ You can switch providers at any time with `hermes model` — no code changes, no ## 3. Start Chatting ```bash -hermes +hermes # classic CLI +hermes --tui # modern Ink TUI (recommended) ``` That's it! You'll see a welcome banner with your model, available tools, and skills. Type a message and press Enter. +:::tip Pick your interface +Hermes ships with two terminal interfaces: the classic `prompt_toolkit` CLI and a newer [Ink TUI](../user-guide/tui.md) with modal overlays, mouse selection, and non-blocking input. Both share the same sessions, slash commands, and config — try each with `hermes` vs `hermes --tui`. +::: + ``` ❯ What can you help me with? ``` diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index fb93cf6480..f88cd12bc5 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -27,6 +27,8 @@ hermes [global-options] [subcommand/options] | `--worktree`, `-w` | Start in an isolated git worktree for parallel-agent workflows. | | `--yolo` | Bypass dangerous-command approval prompts. | | `--pass-session-id` | Include the session ID in the agent's system prompt. | +| `--tui` | Launch the [Ink TUI](../user-guide/tui.md) instead of the classic CLI. Equivalent to `HERMES_TUI=1`. | +| `--dev` | With `--tui`: run the TypeScript sources directly via `tsx` instead of the prebuilt bundle (for TUI contributors). | ## Top-level commands diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 63844b3f93..92ac45b5c2 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -342,6 +342,13 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `HERMES_BACKGROUND_NOTIFICATIONS` | Background process notification mode in gateway: `all` (default), `result`, `error`, `off` | | `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) | +## Interface + +| Variable | Description | +|----------|-------------| +| `HERMES_TUI` | Launch the [Ink TUI](../user-guide/tui.md) instead of the classic CLI when set to `1`. Equivalent to passing `--tui`. | +| `HERMES_TUI_DIR` | Path to a prebuilt `ui-tui/` directory (must contain `dist/entry.js` and populated `node_modules`). Used by distros and Nix to skip the first-launch `npm install`. | + ## Cron Scheduler | Variable | Description | diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index 43d12611f9..e8201d5922 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -8,6 +8,10 @@ description: "Master the Hermes Agent terminal interface — commands, keybindin Hermes Agent's CLI is a full terminal user interface (TUI) — not a web UI. It features multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output. Built for people who live in the terminal. +:::tip +Hermes also ships a modern Node-based terminal UI with modal overlays, mouse selection, and non-blocking input. Launch it with `hermes --tui` — see the [Ink TUI](tui.md) guide. +::: + ## Running the CLI ```bash diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md new file mode 100644 index 0000000000..d79116ae5b --- /dev/null +++ b/website/docs/user-guide/tui.md @@ -0,0 +1,142 @@ +--- +sidebar_position: 2 +title: "Ink TUI" +description: "Launch the modern Node-based terminal UI for Hermes — mouse-friendly, rich overlays, and non-blocking input." +--- + +# Ink TUI + +The Ink TUI is the modern front-end for Hermes — a Node/React terminal UI backed by the same Python runtime as the [Classic CLI](cli.md). Same agent, same sessions, same slash commands; a cleaner, more responsive surface for interacting with them. + +It's the recommended way to run Hermes interactively. + +## Launch + +```bash +# Launch the Ink TUI +hermes --tui + +# Resume the latest Ink session (falls back to the latest classic session) +hermes --tui -c +hermes --tui --continue + +# Resume a specific session by ID or title +hermes --tui -r 20260409_000000_aa11bb +hermes --tui --resume "my t0p session" + +# Run source directly — skips the prebuild step (for TUI contributors) +hermes --tui --dev +``` + +You can also enable it via env var: + +```bash +export HERMES_TUI=1 +hermes # now uses the Ink TUI +hermes chat # same +``` + +The classic CLI remains available as the default. Anything documented in [CLI Interface](cli.md) — slash commands, quick commands, skill preloading, personalities, multi-line input, interrupts — works in the Ink TUI identically. + +## Why the Ink TUI + +- **Instant first frame** — the banner paints before the app finishes loading, so the terminal never feels frozen while Hermes is starting. +- **Non-blocking input** — type and queue messages before the session is ready. Your first prompt sends the moment the agent comes online. +- **Rich overlays** — model picker, session picker, approval and clarification prompts all render as modal panels rather than inline flows. +- **Live session panel** — tools and skills fill in progressively as they initialize. +- **Mouse-friendly selection** — drag to highlight with a uniform background instead of SGR inverse. Copy with your terminal's normal copy gesture. +- **Alternate-screen rendering** — differential updates mean no flicker when streaming, no scrollback clutter after you quit. +- **Composer affordances** — inline paste-collapse for long snippets, image paste from the clipboard (`Alt+V`), bracketed-paste safety. + +Same [skins](features/skins.md) and [personalities](features/personality.md) apply. Switch mid-session with `/skin ares`, `/personality pirate`, `/theme daylight`, and the UI repaints live. + +## Requirements + +- **Node.js** ≥ 20 — the TUI runs as a subprocess launched from the Python CLI. `hermes doctor` verifies this. +- **TTY** — like the classic CLI, piping stdin or running in non-interactive environments falls back to single-query mode. + +On first launch Hermes installs the TUI's Node dependencies into `ui-tui/node_modules` (one-time, a few seconds). Subsequent launches are fast. If you pull a new Hermes version, the TUI bundle is rebuilt automatically when sources are newer than the dist. + +### External prebuild + +Distributions that ship a prebuilt bundle (Nix, system packages) can point Hermes at it: + +```bash +export HERMES_TUI_DIR=/path/to/prebuilt/ui-tui +hermes --tui +``` + +The directory must contain `dist/entry.js` and an up-to-date `node_modules`. + +## Keybindings + +Keybindings match the [Classic CLI](cli.md#keybindings) exactly. The only behavioral differences: + +- **Mouse drag** highlights text with a uniform selection background. +- **`Ctrl+V`** pastes text from your clipboard directly into the composer; multi-line pastes stay on one row until you expand them. +- **Slash autocompletion** opens as a floating panel with descriptions, not an inline dropdown. + +## Slash commands + +All slash commands work unchanged. A few are TUI-owned — they produce richer output or render as overlays rather than inline panels: + +| Command | TUI behavior | +|---------|--------------| +| `/help` | Overlay with categorized commands, arrow-key navigable | +| `/sessions` | Modal session picker — preview, title, token totals, resume inline | +| `/model` | Modal model picker grouped by provider, with cost hints | +| `/skin` | Live preview — theme change applies as you browse | +| `/details` | Toggle verbose tool-call details in the transcript | +| `/usage` | Rich token / cost / context panel | + +Every other slash command (including installed skills, quick commands, and personality toggles) works identically to the classic CLI. See [Slash Commands Reference](../reference/slash-commands.md). + +## Status line + +The TUI's status line tracks agent state in real time: + +| Status | Meaning | +|--------|---------| +| `starting agent…` | Session ID is live; tools and skills still coming online. You can type — messages queue and send when ready. | +| `ready` | Agent is idle, accepting input. | +| `thinking…` / `running…` | Agent is reasoning or running a tool. | +| `interrupted` | Current turn was cancelled; press Enter to send again. | +| `forging session…` / `resuming…` | Initial connect or `--resume` handshake. | + +The per-skin status-bar colors and thresholds are shared with the classic CLI — see [Skins](features/skins.md) for customization. + +## Configuration + +The TUI respects all standard Hermes config: `~/.hermes/config.yaml`, profiles, personalities, skins, quick commands, credential pools, memory providers, tool/skill enablement. No TUI-specific config file exists. + +A handful of keys tune the TUI surface specifically: + +```yaml +display: + skin: default # any built-in or custom skin + personality: helpful + details_mode: compact # or "verbose" — default tool-call detail level + mouse_tracking: true # disable if your terminal conflicts with mouse reporting +``` + +`/details on` / `/details off` / `/details cycle` toggle this at runtime. + +## Sessions + +Sessions are shared between the Ink TUI and the classic CLI — both write to the same `~/.hermes/state.db`. You can start a session in one, resume in the other. The session picker surfaces sessions from both sources, with a source tag. + +See [Sessions](sessions.md) for lifecycle, search, compression, and export. + +## Reverting to the classic CLI + +Launching `hermes` (without `--tui`) stays on the classic CLI. To make a machine prefer the TUI, set `HERMES_TUI=1` in your shell profile. To go back, unset it. + +If the TUI fails to launch (no Node, missing bundle, TTY issue), Hermes prints a diagnostic and falls back — rather than leaving you stuck. + +## See also + +- [CLI Interface](cli.md) — full slash command and keybinding reference (shared) +- [Sessions](sessions.md) — resume, branch, and history +- [Skins & Themes](features/skins.md) — theme the banner, status bar, and overlays +- [Voice Mode](features/voice-mode.md) — works in both interfaces +- [Configuration](configuration.md) — all config keys diff --git a/website/sidebars.ts b/website/sidebars.ts index b1f7fcf59a..c84184c4e6 100644 --- a/website/sidebars.ts +++ b/website/sidebars.ts @@ -21,6 +21,7 @@ const sidebars: SidebarsConfig = { collapsed: true, items: [ 'user-guide/cli', + 'user-guide/tui', 'user-guide/configuration', 'user-guide/sessions', 'user-guide/profiles', From 7ffefc2d6c29ed7f3619fc8f35b6ed3cf1524a45 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 19:38:21 -0500 Subject: [PATCH 135/157] docs(tui): rename "Ink TUI" to just "TUI" throughout user-facing surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Ink" is the React reconciler — implementation detail, not branding. Consistent naming: the classic CLI is the CLI, the new one is the TUI. Updated docs: user-guide/tui.md, user-guide/cli.md cross-link, quickstart, cli-commands reference, environment-variables reference. Updated code: main.py --tui help text, server.py user-visible setup error, AGENTS.md "TUI Architecture" section. Kept "Ink" only where it is literally the library (hermes-ink internal source comments, AGENTS.md tree note flagging ui-tui/ as a React/Ink dir). --- AGENTS.md | 4 ++-- hermes_cli/main.py | 8 ++++---- tui_gateway/server.py | 2 +- website/docs/getting-started/quickstart.md | 4 ++-- website/docs/reference/cli-commands.md | 2 +- .../docs/reference/environment-variables.md | 2 +- website/docs/user-guide/cli.md | 2 +- website/docs/user-guide/tui.md | 20 +++++++++---------- 8 files changed, 22 insertions(+), 22 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 83c32cc800..40d612309b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,7 +64,7 @@ hermes-agent/ │ ├── src/components/ # Ink components (branding, markdown, prompts, pickers, etc.) │ ├── src/hooks/ # useCompletion, useInputHistory, useQueue, useVirtualHistory │ └── src/lib/ # Pure helpers (history, osc52, text, rpc, messages) -├── tui_gateway/ # Python JSON-RPC backend for Ink TUI +├── tui_gateway/ # Python JSON-RPC backend for the TUI │ ├── entry.py # stdio entrypoint │ ├── server.py # RPC handlers and session logic │ ├── render.py # Optional rich/ANSI bridge @@ -194,7 +194,7 @@ if canonical == "mycommand": ## TUI Architecture (ui-tui + tui_gateway) -The Ink TUI is a full replacement for the PT CLI, activated via `hermes --tui` or `HERMES_TUI=1`. +The TUI is a full replacement for the PT CLI, activated via `hermes --tui` or `HERMES_TUI=1`. ### Process Model diff --git a/hermes_cli/main.py b/hermes_cli/main.py index cc2bf55d9f..560b95adf4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -843,7 +843,7 @@ def _ensure_tui_node() -> None: def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: - """Ink TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" + """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" _ensure_tui_node() def _node_bin(bin: str)-> str: @@ -925,7 +925,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: return [node, str(root / "dist" / "entry.js")], root def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): - """Replace current process with the Ink TUI.""" + """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" env = os.environ.copy() @@ -5236,7 +5236,7 @@ For more help on a command: "--tui", action="store_true", default=False, - help="Launch the Ink-based terminal UI instead of the classic REPL" + help="Launch the modern TUI instead of the classic REPL" ) parser.add_argument( "--dev", @@ -5349,7 +5349,7 @@ For more help on a command: "--tui", action="store_true", default=False, - help="Launch the Ink-based terminal UI instead of the classic REPL" + help="Launch the modern TUI instead of the classic REPL" ) chat_parser.add_argument( "--dev", diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 262f9abb0d..6e9249ae74 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1985,7 +1985,7 @@ def _cli_exec_blocked(argv: list[str]) -> str | None: return "bare `hermes` is interactive — use `/hermes chat -q …` or run `hermes` in another terminal" a0 = argv[0].lower() if a0 == "setup": - return "`hermes setup` needs a full terminal — run it outside the Ink UI" + return "`hermes setup` needs a full terminal — run it outside the TUI" if a0 == "gateway": return "`hermes gateway` is long-running — run it in another terminal" if a0 == "sessions" and len(argv) > 1 and argv[1].lower() == "browse": diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md index 997b0acf35..1f721586c9 100644 --- a/website/docs/getting-started/quickstart.md +++ b/website/docs/getting-started/quickstart.md @@ -78,13 +78,13 @@ You can switch providers at any time with `hermes model` — no code changes, no ```bash hermes # classic CLI -hermes --tui # modern Ink TUI (recommended) +hermes --tui # modern TUI (recommended) ``` That's it! You'll see a welcome banner with your model, available tools, and skills. Type a message and press Enter. :::tip Pick your interface -Hermes ships with two terminal interfaces: the classic `prompt_toolkit` CLI and a newer [Ink TUI](../user-guide/tui.md) with modal overlays, mouse selection, and non-blocking input. Both share the same sessions, slash commands, and config — try each with `hermes` vs `hermes --tui`. +Hermes ships with two terminal interfaces: the classic `prompt_toolkit` CLI and a newer [TUI](../user-guide/tui.md) with modal overlays, mouse selection, and non-blocking input. Both share the same sessions, slash commands, and config — try each with `hermes` vs `hermes --tui`. ::: ``` diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index f88cd12bc5..6b08552676 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -27,7 +27,7 @@ hermes [global-options] [subcommand/options] | `--worktree`, `-w` | Start in an isolated git worktree for parallel-agent workflows. | | `--yolo` | Bypass dangerous-command approval prompts. | | `--pass-session-id` | Include the session ID in the agent's system prompt. | -| `--tui` | Launch the [Ink TUI](../user-guide/tui.md) instead of the classic CLI. Equivalent to `HERMES_TUI=1`. | +| `--tui` | Launch the [TUI](../user-guide/tui.md) instead of the classic CLI. Equivalent to `HERMES_TUI=1`. | | `--dev` | With `--tui`: run the TypeScript sources directly via `tsx` instead of the prebuilt bundle (for TUI contributors). | ## Top-level commands diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 92ac45b5c2..fc6cfa58fe 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -346,7 +346,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | Variable | Description | |----------|-------------| -| `HERMES_TUI` | Launch the [Ink TUI](../user-guide/tui.md) instead of the classic CLI when set to `1`. Equivalent to passing `--tui`. | +| `HERMES_TUI` | Launch the [TUI](../user-guide/tui.md) instead of the classic CLI when set to `1`. Equivalent to passing `--tui`. | | `HERMES_TUI_DIR` | Path to a prebuilt `ui-tui/` directory (must contain `dist/entry.js` and populated `node_modules`). Used by distros and Nix to skip the first-launch `npm install`. | ## Cron Scheduler diff --git a/website/docs/user-guide/cli.md b/website/docs/user-guide/cli.md index e8201d5922..62e70e3cc3 100644 --- a/website/docs/user-guide/cli.md +++ b/website/docs/user-guide/cli.md @@ -9,7 +9,7 @@ description: "Master the Hermes Agent terminal interface — commands, keybindin Hermes Agent's CLI is a full terminal user interface (TUI) — not a web UI. It features multiline editing, slash-command autocomplete, conversation history, interrupt-and-redirect, and streaming tool output. Built for people who live in the terminal. :::tip -Hermes also ships a modern Node-based terminal UI with modal overlays, mouse selection, and non-blocking input. Launch it with `hermes --tui` — see the [Ink TUI](tui.md) guide. +Hermes also ships a modern TUI with modal overlays, mouse selection, and non-blocking input. Launch it with `hermes --tui` — see the [TUI](tui.md) guide. ::: ## Running the CLI diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index d79116ae5b..2276254578 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -1,22 +1,22 @@ --- sidebar_position: 2 -title: "Ink TUI" -description: "Launch the modern Node-based terminal UI for Hermes — mouse-friendly, rich overlays, and non-blocking input." +title: "TUI" +description: "Launch the modern terminal UI for Hermes — mouse-friendly, rich overlays, and non-blocking input." --- -# Ink TUI +# TUI -The Ink TUI is the modern front-end for Hermes — a Node/React terminal UI backed by the same Python runtime as the [Classic CLI](cli.md). Same agent, same sessions, same slash commands; a cleaner, more responsive surface for interacting with them. +The TUI is the modern front-end for Hermes — a terminal UI backed by the same Python runtime as the [Classic CLI](cli.md). Same agent, same sessions, same slash commands; a cleaner, more responsive surface for interacting with them. It's the recommended way to run Hermes interactively. ## Launch ```bash -# Launch the Ink TUI +# Launch the TUI hermes --tui -# Resume the latest Ink session (falls back to the latest classic session) +# Resume the latest TUI session (falls back to the latest classic session) hermes --tui -c hermes --tui --continue @@ -32,13 +32,13 @@ You can also enable it via env var: ```bash export HERMES_TUI=1 -hermes # now uses the Ink TUI +hermes # now uses the TUI hermes chat # same ``` -The classic CLI remains available as the default. Anything documented in [CLI Interface](cli.md) — slash commands, quick commands, skill preloading, personalities, multi-line input, interrupts — works in the Ink TUI identically. +The classic CLI remains available as the default. Anything documented in [CLI Interface](cli.md) — slash commands, quick commands, skill preloading, personalities, multi-line input, interrupts — works in the TUI identically. -## Why the Ink TUI +## Why the TUI - **Instant first frame** — the banner paints before the app finishes loading, so the terminal never feels frozen while Hermes is starting. - **Non-blocking input** — type and queue messages before the session is ready. Your first prompt sends the moment the agent comes online. @@ -123,7 +123,7 @@ display: ## Sessions -Sessions are shared between the Ink TUI and the classic CLI — both write to the same `~/.hermes/state.db`. You can start a session in one, resume in the other. The session picker surfaces sessions from both sources, with a source tag. +Sessions are shared between the TUI and the classic CLI — both write to the same `~/.hermes/state.db`. You can start a session in one, resume in the other. The session picker surfaces sessions from both sources, with a source tag. See [Sessions](sessions.md) for lifecycle, search, compression, and export. From 23212d6b40120e1e644860821f4598ffe3c8c265 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 19:39:09 -0500 Subject: [PATCH 136/157] =?UTF-8?q?docs:=20kill=20"PT"=20shorthand=20?= =?UTF-8?q?=E2=80=94=20say=20"classic=20(prompt=5Ftoolkit)=20CLI"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "PT" was internal shorthand for prompt_toolkit that leaked into AGENTS.md and the TUI post-mortem. Spell it out. - AGENTS.md: "PT CLI" → "classic (prompt_toolkit) CLI" - docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md: both hits --- AGENTS.md | 2 +- docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 40d612309b..beac310b6c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,7 +194,7 @@ if canonical == "mycommand": ## TUI Architecture (ui-tui + tui_gateway) -The TUI is a full replacement for the PT CLI, activated via `hermes --tui` or `HERMES_TUI=1`. +The TUI is a full replacement for the classic (prompt_toolkit) CLI, activated via `hermes --tui` or `HERMES_TUI=1`. ### Process Model 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 index 9050d2c0c6..0210a878cb 100644 --- a/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md +++ b/docs/plans/2026-04-01-ink-gateway-tui-migration-plan.md @@ -1,6 +1,6 @@ # Ink Gateway TUI Migration — Post-mortem -Planned: 2026-04-01 · Delivered: 2026-04 · Status: shipped, PT path still present +Planned: 2026-04-01 · Delivered: 2026-04 · Status: shipped, classic (prompt_toolkit) CLI still present ## What Shipped @@ -104,5 +104,5 @@ src/ ## What's Still Open -- **PT path not deleted.** `cli.py` still has ~80 `prompt_toolkit` references; classic REPL is still the default when `--tui` is absent. The original plan's "Cut 4 · PT path removal later" hasn't happened. +- **Classic CLI not deleted.** `cli.py` still has ~80 `prompt_toolkit` references; classic REPL is still the default when `--tui` is absent. The original plan's "Cut 4 · prompt_toolkit removal later" hasn't happened. - **No config-file opt-in.** `HERMES_EXPERIMENTAL_TUI` and `display.experimental_tui` were never built; only the CLI flag exists. Fine for now — if we want "default to TUI", a single line in `main.py` flips it. From aedc767c664027b4ef17d1a5d0841fe585630ba0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 20:14:25 -0500 Subject: [PATCH 137/157] feat(tui): put the kawaii face+verb ticker in the status bar, not the thinking panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status bar was showing stale lifecycle text ("running…") while the face+verb stream flickered through the thinking panel as Python pushed thinking.delta events. That's backwards — the face ticker is the primary "I'm alive" signal, it belongs in the status bar; the thinking panel is for substantive reasoning and tool activity. Status bar now reads `ui.busy`: when true, renders a local `` cycling FACES × VERBS on a 2.5s interval, unaffected by server events. When false, the bar shows the actual status string (ready, starting agent…, interrupted, etc.). Side effect: `scheduleThinkingStatus` still patches `ui.status` with Python's face text, but while busy the bar ignores that string and uses the ticker instead. No server-side changes needed — Python keeps emitting thinking.delta as a liveness heartbeat, the TUI just doesn't let it fight the status bar. --- docs/skins/example-skin.yaml | 63 +++++++++++-------- tui_gateway/server.py | 2 + .../src/__tests__/createSlashHandler.test.ts | 4 +- ui-tui/src/app/createGatewayEventHandler.ts | 11 +++- ui-tui/src/app/slash/commands/core.ts | 2 +- ui-tui/src/components/appChrome.tsx | 24 ++++++- ui-tui/src/components/appLayout.tsx | 3 +- ui-tui/src/components/branding.tsx | 7 ++- ui-tui/src/gatewayTypes.ts | 2 + ui-tui/src/theme.ts | 29 +++++++-- website/docs/user-guide/tui.md | 2 +- 11 files changed, 109 insertions(+), 40 deletions(-) diff --git a/docs/skins/example-skin.yaml b/docs/skins/example-skin.yaml index b81ae00f8d..fb0be89da6 100644 --- a/docs/skins/example-skin.yaml +++ b/docs/skins/example-skin.yaml @@ -6,6 +6,11 @@ # All fields are optional — missing values inherit from the default skin. # Activate with: /skin or display.skin: in config.yaml # +# Keys are marked: +# (both) — applies to both the classic CLI and the TUI +# (classic) — classic CLI only (see hermes --tui in user-guide/tui.md) +# (tui) — TUI only +# # See hermes_cli/skin_engine.py for the full schema reference. # ============================================================================ @@ -14,43 +19,47 @@ name: example description: An example custom skin — copy and modify this template # ── Colors ────────────────────────────────────────────────────────────────── -# Hex color values for Rich markup. These control the CLI's visual palette. +# Hex color values. These control the visual palette. colors: - # Banner panel (the startup welcome box) + # Banner panel (the startup welcome box) — (both) banner_border: "#CD7F32" # Panel border banner_title: "#FFD700" # Panel title text banner_accent: "#FFBF00" # Section headers (Available Tools, Skills, etc.) banner_dim: "#B8860B" # Dim/muted text (separators, model info) banner_text: "#FFF8DC" # Body text (tool names, skill names) - # UI elements - ui_accent: "#FFBF00" # General accent color + # UI elements — (both) + ui_accent: "#FFBF00" # General accent (falls back to banner_accent) ui_label: "#4dd0e1" # Labels ui_ok: "#4caf50" # Success indicators ui_error: "#ef5350" # Error indicators ui_warn: "#ffa726" # Warning indicators # Input area - prompt: "#FFF8DC" # Prompt text color - input_rule: "#CD7F32" # Horizontal rule around input + prompt: "#FFF8DC" # Prompt text / `❯` glyph color (both) + input_rule: "#CD7F32" # Horizontal rule above input (classic) - # Response box - response_border: "#FFD700" # Response box border (ANSI color) + # Response box — (classic) + response_border: "#FFD700" # Response box border - # Session display - session_label: "#DAA520" # Session label - session_border: "#8B8682" # Session ID dim color + # Session display — (both) + session_label: "#DAA520" # "Session: " label + session_border: "#8B8682" # Session ID text - # TUI surfaces - status_bar_bg: "#1a1a2e" # Status / usage bar background - voice_status_bg: "#1a1a2e" # Voice-mode badge background - completion_menu_bg: "#1a1a2e" # Completion list background - completion_menu_current_bg: "#333355" # Active completion row background - completion_menu_meta_bg: "#1a1a2e" # Completion meta column background - completion_menu_meta_current_bg: "#333355" # Active completion meta background + # TUI / CLI surfaces — (classic: status bar, voice badge, completion meta) + status_bar_bg: "#1a1a2e" # Status / usage bar background (classic) + voice_status_bg: "#1a1a2e" # Voice-mode badge background (classic) + completion_menu_bg: "#1a1a2e" # Completion list background (both) + completion_menu_current_bg: "#333355" # Active completion row background (both) + completion_menu_meta_bg: "#1a1a2e" # Completion meta column bg (classic) + completion_menu_meta_current_bg: "#333355" # Active meta bg (classic) + + # Drag-to-select background — (tui) + selection_bg: "#3a3a55" # Uniform selection highlight in the TUI # ── Spinner ───────────────────────────────────────────────────────────────── -# Customize the animated spinner shown during API calls and tool execution. +# (classic) — the TUI uses its own animated indicators; spinner config here +# is only read by the classic prompt_toolkit CLI. spinner: # Faces shown while waiting for the API response waiting_faces: @@ -78,17 +87,17 @@ spinner: # - ["⟪▲", "▲⟫"] # ── Branding ──────────────────────────────────────────────────────────────── -# Text strings used throughout the CLI interface. +# Text strings used throughout the interface. branding: - agent_name: "Hermes Agent" # Banner title, about display - welcome: "Welcome! Type your message or /help for commands." - goodbye: "Goodbye! ⚕" # Exit message - response_label: " ⚕ Hermes " # Response box header label - prompt_symbol: "❯ " # Input prompt symbol - help_header: "(^_^)? Available Commands" # /help header text + agent_name: "Hermes Agent" # (both) Banner title, about display + welcome: "Welcome! Type your message or /help for commands." # (both) + goodbye: "Goodbye! ⚕" # (both) Exit message + response_label: " ⚕ Hermes " # (classic) Response box header label + prompt_symbol: "❯ " # (both) Input prompt glyph + help_header: "(^_^)? Available Commands" # (both) /help overlay title # ── Tool Output ───────────────────────────────────────────────────────────── -# Character used as the prefix for tool output lines. +# Character used as the prefix for tool output lines. (both) # Default is "┊" (thin dotted vertical line). Some alternatives: # "╎" (light triple dash vertical) # "▏" (left one-eighth block) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 6e9249ae74..c8ae0be85c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -316,6 +316,8 @@ def resolve_skin() -> dict: "branding": skin.branding, "banner_logo": skin.banner_logo, "banner_hero": skin.banner_hero, + "tool_prefix": skin.tool_prefix, + "help_header": (skin.branding or {}).get("help_header", ""), } except Exception: return {} diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 6a48bc1be8..9e1db99463 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -102,7 +102,7 @@ describe('createSlashHandler', () => { const h = createSlashHandler(ctx) expect(h('/zzz')).toBe(true) await vi.waitFor(() => { - expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array)) + expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array)) }) }) @@ -119,7 +119,7 @@ describe('createSlashHandler', () => { }) expect(createSlashHandler(ctx)('/h')).toBe(true) - expect(ctx.transcript.panel).toHaveBeenCalledWith('Commands', expect.any(Array)) + expect(ctx.transcript.panel).toHaveBeenCalledWith(expect.any(String), expect.any(Array)) }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 02057e807e..53269a1751 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -16,7 +16,16 @@ const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') const applySkin = (s: GatewaySkin) => - patchUiState({ theme: fromSkin(s.colors ?? {}, s.branding ?? {}, s.banner_logo ?? '', s.banner_hero ?? '') }) + patchUiState({ + theme: fromSkin( + s.colors ?? {}, + s.branding ?? {}, + s.banner_logo ?? '', + s.banner_hero ?? '', + s.tool_prefix ?? '', + s.help_header ?? '' + ) + }) const dropBgTask = (taskId: string) => patchUiState(state => { diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 4b619bed5a..11c1107736 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -55,7 +55,7 @@ export const coreCommands: SlashCommand[] = [ }) sections.push({ rows: HOTKEYS, title: 'Hotkeys' }) - ctx.transcript.panel('Commands', sections) + ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections) } }, diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 4e55a53ba8..1000adbb68 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -1,12 +1,32 @@ import { Box, type ScrollBoxHandle, Text } from '@hermes/ink' import { type ReactNode, type RefObject, useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { FACES } from '../content/faces.js' +import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' import { stickyPromptFromViewport } from '../domain/viewport.js' import { fmtK } from '../lib/text.js' import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' +const FACE_TICK_MS = 2500 + +function FaceTicker({ color }: { color: string }) { + const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) + + useEffect(() => { + const id = setInterval(() => setTick(n => n + 1), FACE_TICK_MS) + + return () => clearInterval(id) + }, []) + + return ( + + {FACES[tick % FACES.length]} {VERBS[tick % VERBS.length]}… + + ) +} + function ctxBarColor(pct: number | undefined, t: Theme) { if (pct == null) { return t.color.dim @@ -73,6 +93,7 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { export function StatusRule({ cwdLabel, cols, + busy, status, statusColor, model, @@ -84,6 +105,7 @@ export function StatusRule({ }: { cwdLabel: string cols: number + busy: boolean status: string statusColor: string model: string @@ -111,7 +133,7 @@ export function StatusRule({ {'─ '} - {status} + {busy ? : {status}} │ {model} {ctxLabel ? │ {ctxLabel} : null} {bar ? ( diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index e8ae95b1e7..39056bfc31 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -138,6 +138,7 @@ const ComposerPane = memo(function ComposerPane({ {ui.statusBar && ( $ ) : ( - + {composer.inputBuf.length ? ' ' : `${ui.theme.brand.prompt} `} )} diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 46f6b667fe..859ff9bee7 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -106,7 +106,12 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string {cwd} - {sid && Session: {sid}} + {sid && ( + + Session: + {sid} + + )} )} diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index cd98dc9d47..31c58896b8 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -5,6 +5,8 @@ export interface GatewaySkin { banner_logo?: string branding?: Record colors?: Record + help_header?: string + tool_prefix?: string } export interface GatewayCompletionItem { diff --git a/ui-tui/src/theme.ts b/ui-tui/src/theme.ts index ddd722e538..88bc3c3908 100644 --- a/ui-tui/src/theme.ts +++ b/ui-tui/src/theme.ts @@ -12,6 +12,10 @@ export interface ThemeColors { error: string warn: string + prompt: string + sessionLabel: string + sessionBorder: string + statusBg: string statusFg: string statusGood: string @@ -35,6 +39,7 @@ export interface ThemeBrand { welcome: string goodbye: string tool: string + helpHeader: string } export interface Theme { @@ -88,6 +93,10 @@ export const DEFAULT_THEME: Theme = { error: '#ef5350', warn: '#ffa726', + prompt: '#FFF8DC', + sessionLabel: '#B8860B', + sessionBorder: '#B8860B', + statusBg: '#1a1a2e', statusFg: '#C0C0C0', statusGood: '#8FBC8F', @@ -109,7 +118,8 @@ export const DEFAULT_THEME: Theme = { prompt: '❯', welcome: 'Type your message or /help for commands.', goodbye: 'Goodbye! ⚕', - tool: '┊' + tool: '┊', + helpHeader: '(^_^)? Commands' }, bannerLogo: '', @@ -122,20 +132,24 @@ export function fromSkin( colors: Record, branding: Record, bannerLogo = '', - bannerHero = '' + bannerHero = '', + toolPrefix = '', + helpHeader = '' ): Theme { const d = DEFAULT_THEME const c = (k: string) => colors[k] + const amber = c('ui_accent') ?? c('banner_accent') ?? d.color.amber const accent = c('banner_accent') ?? c('banner_title') ?? d.color.amber + const dim = c('banner_dim') ?? d.color.dim return { color: { gold: c('banner_title') ?? d.color.gold, - amber: c('banner_accent') ?? d.color.amber, + amber, bronze: c('banner_border') ?? d.color.bronze, cornsilk: c('banner_text') ?? d.color.cornsilk, - dim: c('banner_dim') ?? d.color.dim, + dim, completionBg: c('completion_menu_bg') ?? '#FFFFFF', completionCurrentBg: c('completion_menu_current_bg') ?? mix('#FFFFFF', accent, 0.25), @@ -144,6 +158,10 @@ export function fromSkin( error: c('ui_error') ?? d.color.error, warn: c('ui_warn') ?? d.color.warn, + prompt: c('prompt') ?? c('banner_text') ?? d.color.prompt, + sessionLabel: c('session_label') ?? dim, + sessionBorder: c('session_border') ?? dim, + statusBg: d.color.statusBg, statusFg: d.color.statusFg, statusGood: c('ui_ok') ?? d.color.statusGood, @@ -165,7 +183,8 @@ export function fromSkin( prompt: branding.prompt_symbol ?? d.brand.prompt, welcome: branding.welcome ?? d.brand.welcome, goodbye: branding.goodbye ?? d.brand.goodbye, - tool: d.brand.tool + tool: toolPrefix || d.brand.tool, + helpHeader: branding.help_header ?? (helpHeader || d.brand.helpHeader) }, bannerLogo, diff --git a/website/docs/user-guide/tui.md b/website/docs/user-guide/tui.md index 2276254578..a296a63f7b 100644 --- a/website/docs/user-guide/tui.md +++ b/website/docs/user-guide/tui.md @@ -48,7 +48,7 @@ The classic CLI remains available as the default. Anything documented in [CLI In - **Alternate-screen rendering** — differential updates mean no flicker when streaming, no scrollback clutter after you quit. - **Composer affordances** — inline paste-collapse for long snippets, image paste from the clipboard (`Alt+V`), bracketed-paste safety. -Same [skins](features/skins.md) and [personalities](features/personality.md) apply. Switch mid-session with `/skin ares`, `/personality pirate`, `/theme daylight`, and the UI repaints live. +Same [skins](features/skins.md) and [personalities](features/personality.md) apply. Switch mid-session with `/skin ares`, `/personality pirate`, and the UI repaints live. Skin keys are marked `(both)`, `(classic)`, or `(tui)` in [`example-skin.yaml`](https://github.com/NousResearch/hermes-agent/blob/main/docs/skins/example-skin.yaml) so you can see at a glance what applies where — the TUI honors the banner palette, UI colors, prompt glyph/color, session display, completion menu, selection bg, `tool_prefix`, and `help_header`. ## Requirements From 26859e3fcbdb031512385718bc420a5e9bfee403 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 20:27:06 -0500 Subject: [PATCH 138/157] fix(tui): keep the Thinking expander visible for the whole turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `hasThinking = !!cot || reasoningActive || (busy && !hasTools)` so the moment a tool started streaming (`hasTools` → true) the expander vanished mid-turn. If the model also produced no `reasoning.delta` events (reasoning-less models, or reasoning arriving after tools), the whole turn ran with no Thinking row — then `message.complete` populated `msg.thinking` from the payload's post-hoc reasoning trace and the expander suddenly appeared in the transcript AFTER the turn. Drop the `!hasTools` restriction. The Thinking row now anchors for the entire `busy` window; tools and thinking coexist as sibling sections (they already did — the exclusion was a UX mistake). Reasoning-less models show a dim empty header; streaming models show live content; tool-interleaved turns keep the anchor visible throughout. --- ui-tui/src/components/thinking.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 76dbefe579..8c57eeaad0 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -692,7 +692,7 @@ export const ToolTrail = memo(function ToolTrail({ const hasTools = groups.length > 0 const hasSubagents = subagents.length > 0 const hasMeta = meta.length > 0 - const hasThinking = !!cot || reasoningActive || (busy && !hasTools) + const hasThinking = !!cot || reasoningActive || busy const thinkingLive = reasoningActive || reasoningStreaming const tokenCount = reasoningTokens !== undefined ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0 From 15096903c75d5259d18a226872742e39214517c1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 20:35:46 -0500 Subject: [PATCH 139/157] fix(tui): keep the newline above the streaming assistant text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalized assistant messages rendered the thinking/tools trail inside MessageLine with marginBottom=1 before the response body — giving a clean blank line above the text. The streaming path rendered the progress ToolTrail and the streaming MessageLine as two separate siblings with no margin between, so the in-progress response butted right up against the thinking panel. That's the "newline appears after it's done" jank. Wrap the streaming MessageLine in a Box with marginTop=1 whenever the progress area is visible above it. Same spacing as the finalized version, continuous through the handoff. --- ui-tui/src/components/appLayout.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 39056bfc31..7ee0de88f4 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -73,14 +73,16 @@ const TranscriptPane = memo(function TranscriptPane({ )} {progress.showStreamingArea && ( - + + + )} From 26f3a05c9c56fa24c21fc8b65e5332188578ed84 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 20:39:02 -0500 Subject: [PATCH 140/157] fix(tui): don't clobber busy on the progress panel during streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `appLayout` was passing `busy={ui.busy && !progress.streaming}` into ToolTrail, so the moment `message.delta` fired and streaming began, the panel internally saw `busy=false`. With the prior fix in place (hasThinking = !!cot || reasoningActive || busy), that flipped hasThinking to false and the Thinking expander vanished mid-turn — reappearing only after message.complete when the finalized row rendered with its own internal expander. The `!progress.streaming` override was a defensive guard against the panel implying "still thinking" once the response text was streaming. But that's already handled inside ToolTrail — `streaming` prop on the Thinking component uses `busy && reasoningStreaming`, and reasoningStreaming is already falsey once recordMessageDelta calls endReasoningPhase. Pass plain `busy={ui.busy}`. Panel stays up start-to-finish; handoff to the finalized-message row is continuous. --- ui-tui/src/components/appLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 7ee0de88f4..4f5c772761 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -58,7 +58,7 @@ const TranscriptPane = memo(function TranscriptPane({ {progress.showProgressArea && ( Date: Thu, 16 Apr 2026 20:49:41 -0500 Subject: [PATCH 141/157] refactor(tui): wrap progress panel + streaming body in StreamingAssistant Two improvements: 1. The progress ToolTrail and the streaming MessageLine were two sibling JSX blocks in appLayout with hand-rolled margin glue between them. Extracted into ``, a single component that owns both the trail and the streaming body plus the 1-row gap between them. appLayout just hands it `progress` and theme; the layout logic lives in one place, matching the mental model that these two pieces are one live assistant turn. 2. Thinking token label was hidden when `reasoningTokens === 0` even if the live reasoning text was already populated (the scheduleReasoning timer hadn't ticked, or the model sent no reasoning but the text was coming in via reasoning.delta). Changed the tokenCount fallback from `reasoningTokens !== undefined ? reasoningTokens : estimate` to `reasoningTokens > 0 ? ... : estimate` so the label appears the moment text exists. --- ui-tui/src/components/appLayout.tsx | 92 ++++++++++++++++++++--------- ui-tui/src/components/thinking.tsx | 3 +- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 4f5c772761..545817f23c 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -2,10 +2,12 @@ import { AlternateScreen, Box, NoSelect, ScrollBox, Text } from '@hermes/ink' import { useStore } from '@nanostores/react' import { memo } from 'react' -import type { AppLayoutProps } from '../app/interfaces.js' +import type { AppLayoutProgressProps, AppLayoutProps } from '../app/interfaces.js' import { $isBlocked } from '../app/overlayStore.js' import { $uiState } from '../app/uiStore.js' import { PLACEHOLDER } from '../content/placeholders.js' +import type { Theme } from '../theme.js' +import type { DetailsMode } from '../types.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' import { AppOverlays } from './appOverlays.js' @@ -15,6 +17,58 @@ import { QueuedMessages } from './queuedMessages.js' import { TextInput } from './textInput.js' import { ToolTrail } from './thinking.js' +const StreamingAssistant = memo(function StreamingAssistant({ + busy, + cols, + compact, + detailsMode, + progress, + t +}: { + busy: boolean + cols: number + compact?: boolean + detailsMode: DetailsMode + progress: AppLayoutProgressProps + t: Theme +}) { + if (!progress.showProgressArea && !progress.showStreamingArea) return null + + return ( + + {progress.showProgressArea && ( + + + + )} + + {progress.showStreamingArea && ( + + )} + + ) +}) + const TranscriptPane = memo(function TranscriptPane({ actions, composer, @@ -55,35 +109,15 @@ const TranscriptPane = memo(function TranscriptPane({ {transcript.virtualHistory.bottomSpacer > 0 ? : null} - {progress.showProgressArea && ( - - )} + - {progress.showStreamingArea && ( - - - - )} diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 8c57eeaad0..25f2080818 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -695,7 +695,8 @@ export const ToolTrail = memo(function ToolTrail({ const hasThinking = !!cot || reasoningActive || busy const thinkingLive = reasoningActive || reasoningStreaming - const tokenCount = reasoningTokens !== undefined ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0 + const tokenCount = + reasoningTokens && reasoningTokens > 0 ? reasoningTokens : reasoning ? estimateTokensRough(reasoning) : 0 const toolTokenCount = toolTokens ?? 0 const totalTokenCount = tokenCount + toolTokenCount From 40f2368875a8174416bb1dc6438bcf7910f32ee3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 20:56:47 -0500 Subject: [PATCH 142/157] fix(tui): ungate reasoning events so the Thinking panel shows live tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway was gating `reasoning.delta` and `reasoning.available` behind `_reasoning_visible(sid)` (true iff `display.show_reasoning: true` or `tool_progress_mode: verbose`). With the default config, neither was true — so reasoning events never reached the TUI, `turn.reasoning` stayed empty, `reasoningTokens` stayed 0, and the Thinking expander showed no token label for the whole turn. Tools still reported tokens because `tool.start` had no such gate. Then `message.complete` fired with `payload.reasoning` populated, the TUI saved it into `msg.thinking`, and the finalized row's expander sprouted "~36 tokens" post-hoc. That's the "tokens appear after the turn" jank. Remove the gate on emission. The TUI is responsible for whether to display reasoning content (detailsMode + collapsed expander already handle that). Token counting becomes continuous throughout the turn, matching how tools work. Also dropped the now-unused `_reasoning_visible` and `_session_show_reasoning` helpers. `show_reasoning` config key stays in place — it's still toggled via `/reasoning show|hide` and read elsewhere for potential future TUI-side gating. --- tui_gateway/server.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index c8ae0be85c..2fd4f49e8b 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -388,18 +388,10 @@ def _load_enabled_toolsets() -> list[str] | None: return None -def _session_show_reasoning(sid: str) -> bool: - return bool(_sessions.get(sid, {}).get("show_reasoning", False)) - - def _session_tool_progress_mode(sid: str) -> str: return str(_sessions.get(sid, {}).get("tool_progress_mode", "all") or "all") -def _reasoning_visible(sid: str) -> bool: - return _session_show_reasoning(sid) or _session_tool_progress_mode(sid) == "verbose" - - def _tool_progress_enabled(sid: str) -> bool: return _session_tool_progress_mode(sid) != "off" @@ -705,7 +697,7 @@ def _on_tool_progress( if event_type == "tool.started" and name: _emit("tool.progress", sid, {"name": name, "preview": preview or ""}) return - if event_type == "reasoning.available" and preview and _reasoning_visible(sid): + if event_type == "reasoning.available" and preview: _emit("reasoning.available", sid, {"text": str(preview)}) return if event_type.startswith("subagent."): @@ -739,7 +731,7 @@ def _agent_cbs(sid: str) -> dict: ), tool_gen_callback=lambda name: _tool_progress_enabled(sid) and _emit("tool.generating", sid, {"name": name}), thinking_callback=lambda text: _emit("thinking.delta", sid, {"text": text}), - reasoning_callback=lambda text: _reasoning_visible(sid) and _emit("reasoning.delta", sid, {"text": text}), + reasoning_callback=lambda text: _emit("reasoning.delta", sid, {"text": text}), status_callback=lambda kind, text=None: _status_update(sid, str(kind), None if text is None else str(text)), clarify_callback=lambda q, c: _block("clarify.request", sid, {"question": q, "choices": c}), ) From c74017f405fb4dacb6e09e35906d0f5ca40ba6c7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 21:07:19 -0500 Subject: [PATCH 143/157] fix(tui): sticky prompt correctness + scrollbar re-render thrash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sticky prompt: The loop was skipping `first` (the first row in the viewport) when looking for a user message scrolled above the top edge. If `first` itself was a user row that had just ticked above the viewport, we'd fall through the early-return guard (`role === 'user' && !above`), then walk from `first - 1` backward — never rechecking `first`, never finding anything, returning '' and leaving the sticky empty. This is why it felt "stuck" at the start: one-turn sessions with the user row exactly at/near the top never surfaced the breadcrumb. Collapsed the two branches into one loop starting at `first`: nearest user wins — still-on-screen → empty (redundant to echo), already above → text. Same semantics, covers the gap. Scrollbar: `useSyncExternalStore` snapshot was `scrollTop:vp:scrollHeight` — scrollHeight ticks up by ~1 row on every streamed chunk, forcing a re-render per chunk. Quantized snapshot to the displayed values (`thumbTop:thumbSize:vp`) so we only re-render when the bar actually changes. Drops render count per turn by ~100x during streaming and stops the "constantly resizes" flicker. --- ui-tui/src/components/appChrome.tsx | 13 ++++++++++++- ui-tui/src/domain/viewport.ts | 13 ++++++------- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 1000adbb68..f381057c0c 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -213,6 +213,10 @@ export function StickyPromptTracker({ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) { useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), + // Quantize the scroll snapshot to the values the thumb actually renders + // with — thumbTop + thumbSize + viewport height. Streaming drives + // scrollHeight up by ~1 row at a time, but the quantized thumb usually + // doesn't move, so we skip thousands of render cycles mid-turn. () => { const s = scrollRef.current @@ -220,7 +224,14 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject vp ? Math.max(1, Math.round((vp * vp) / total)) : vp + const travel = Math.max(1, vp - thumb) + const thumbTop = total > vp ? Math.round((top / Math.max(1, total - vp)) * travel) : 0 + + return `${thumbTop}:${thumb}:${vp}` }, () => '' ) diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 783bc52258..3dccc3177f 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -28,16 +28,15 @@ export const stickyPromptFromViewport = ( const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top - if (messages[first]?.role === 'user' && !aboveViewport(first)) { - return '' - } - - for (let i = first - 1; i >= 0; i--) { - if (messages[i]?.role !== 'user' || !aboveViewport(i)) { + // Walk backward from the first visible row. The nearest user message wins: + // if it's still on screen, no sticky is needed; if it's already scrolled + // above the top, its text becomes the floating breadcrumb. + for (let i = first; i >= 0; i--) { + if (messages[i]?.role !== 'user') { continue } - return userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() + return aboveViewport(i) ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : '' } return '' From c730ab8ad70613db2421e6300d1075c1019930ff Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 21:09:50 -0500 Subject: [PATCH 144/157] chore: fmt --- ui-tui/src/app/createGatewayEventHandler.ts | 1 - ui-tui/src/app/useMainApp.ts | 5 ++++- ui-tui/src/app/useSessionLifecycle.ts | 12 +++++++++--- ui-tui/src/bootBanner.ts | 3 +-- ui-tui/src/components/appLayout.tsx | 5 +++-- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 53269a1751..ff7345e2be 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -169,7 +169,6 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } return - case 'session.info': { const info = ev.payload diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 8f4509594e..daa61bccfc 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -383,7 +383,10 @@ export function useMainApp(gw: GatewayClient) { } const next = composerActions.dequeue() - if (next) sendQueued(next) + + if (next) { + sendQueued(next) + } }, [ui.sid, ui.busy, composerActions, composerRefs, sendQueued]) const { pagerPageSize } = useInputHandlers({ diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 3319f7a079..83341108e0 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -114,11 +114,17 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { usage: usageFrom(info) }) - if (info) setHistoryItems([introMsg(info)]) + if (info) { + setHistoryItems([introMsg(info)]) + } - if (info?.credential_warning) sys(`warning: ${info.credential_warning}`) + if (info?.credential_warning) { + sys(`warning: ${info.credential_warning}`) + } - if (msg) sys(msg) + if (msg) { + sys(msg) + } }, [closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) diff --git a/ui-tui/src/bootBanner.ts b/ui-tui/src/bootBanner.ts index 3cbd0a0895..2c85387bd4 100644 --- a/ui-tui/src/bootBanner.ts +++ b/ui-tui/src/bootBanner.ts @@ -20,8 +20,7 @@ const TAGLINE = `${DIM}⚕ Nous Research · Messenger of the Digital Gods${RESET const FALLBACK = `\x1b[1m${GOLD}⚕ NOUS HERMES${RESET}` export function bootBanner(cols: number = process.stdout.columns || 80): string { - const body = - cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK + const body = cols >= LOGO_WIDTH ? LOGO.map((text, i) => `${GRADIENT[i]}${text}${RESET}`).join('\n') : FALLBACK return `\n${body}\n${TAGLINE}\n\n` } diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 545817f23c..7d02503bd1 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -32,7 +32,9 @@ const StreamingAssistant = memo(function StreamingAssistant({ progress: AppLayoutProgressProps t: Theme }) { - if (!progress.showProgressArea && !progress.showStreamingArea) return null + if (!progress.showProgressArea && !progress.showStreamingArea) { + return null + } return ( @@ -117,7 +119,6 @@ const TranscriptPane = memo(function TranscriptPane({ progress={progress} t={ui.theme} /> - From 39231f29c6bf39608a013d06510b1d8ff2bd8ff1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 22:32:53 -0500 Subject: [PATCH 145/157] =?UTF-8?q?refactor(tui):=20/clean=20pass=20across?= =?UTF-8?q?=20ui-tui=20=E2=80=94=2049=20files,=20=E2=88=92217=20LOC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full codebase pass using the /clean doctrine (KISS/DRY, no one-off helpers, no variables-used-once, pure functional where natural, inlined obvious one-liners, killed dead exports, narrowed types, spaced JSX). All contracts preserved — no RPC method, event name, or exported type shape changed. app/ — 15 files, -134 LOC - inlined 4 one-off helpers (titleCase, isLong, statusToneFrom, focusOutside predicate) - stores to arrow-const style (buildUiState, buildTurnState, buildOverlayState plus get/patch/reset triplets) - functional slash/registry byName map (flatMap over for-loops) - dropped dead param `live` in cancelOverlayFromCtrlC - DRY'd duplicate shift() call in scrollWithSelection - consolidated sections.push calls in /help components/ — 12 files, -40 LOC - extracted inline prop types to interfaces at file bottom (13×) - inlined 6 one-off vars (pctLabel, logoW, heroW, cwd, title, hint) - promoted HEART_COLORS + OPTS/LABELS to module scope - JSX sibling spacing across 9 files - un-shadowed `raw` in textInput - components/thinking.tsx + components/markdown.tsx untouched (structurally load-bearing / edge-case-heavy) config content domain protocol/ — 8 files, -77 LOC - tightened 3 regexes (MOUSE_TRACKING, looksLikeSlashCommand, hasInterpolation — dropped stateful lastIndex dance) - dead export ParsedSlashCommand removed - MODES narrowed to `as const`, `.find(m => m === s)` replaces `.includes() ? (as cast) : null` - fortunes.ts hash via reduce - fmtDuration ternary chain - inlined aboveViewport predicate in viewport.ts hooks/ + lib/ — 9 files, -38 LOC - ANSI_RE via String.fromCharCode(27) + WS_RE lifted to module scope (no more eslint-disable no-control-regex) - compactPreview/edgePreview/thinkingPreview → ternary arrows - useCompletion: hoisted pathReplace, moved stale-ref guard earlier - useInputHistory: dropped useCallback wrapper (append is stable) - useVirtualHistory: replaced 4× any with unknown + narrow MeasuredNode interface + one cast site root TS — 3 files, -63 LOC - banner.ts: parseRichMarkup via matchAll instead of exec/lastIndex, artWidth via reduce - gatewayClient.ts: resolvePython candidate list collapse, inlined one-branch guards in dispatch/pushLog/drain/request - types.ts: alpha-sorted ActiveTool / Msg / SudoReq / SecretReq members eslint config - disabled react-hooks/exhaustive-deps on packages/hermes-ink/** (compiled by react/compiler, deps live in $[N] memo arrays that eslint can't introspect) and removed the now-orphan in-file disable directive in ScrollBox.tsx fixes (not from the cleaner pass) - useComposerState: unlinkSync(file) + try/catch → rmSync(file, { force: true }) — kills the no-empty lint error and is more idiomatic - useConfigSync: added setBellOnComplete + setVoiceEnabled to the two useEffect dep arrays (they're stable React setState setters; adding is safe and silences exhaustive-deps) verification - npx eslint src/ packages/ → 0 errors, 0 warnings - npm run type-check → clean - npm test → 50/50 - npm run build → 394.8kb ink-bundle.js, 11ms esbuild - pytest tests/tui_gateway/ tests/test_tui_gateway_server.py tests/hermes_cli/test_tui_resume_flow.py tests/hermes_cli/test_tui_npm_install.py → 57/57 --- ui-tui/eslint.config.mjs | 3 +- .../src/ink/components/ScrollBox.tsx | 2 +- ui-tui/src/app/createGatewayEventHandler.ts | 8 +- ui-tui/src/app/createSlashHandler.ts | 7 +- ui-tui/src/app/overlayStore.ts | 45 +++---- ui-tui/src/app/slash/commands/core.ts | 22 +-- ui-tui/src/app/slash/commands/ops.ts | 2 +- ui-tui/src/app/slash/commands/session.ts | 7 +- ui-tui/src/app/slash/registry.ts | 14 +- ui-tui/src/app/turnStore.ts | 53 +++----- ui-tui/src/app/uiStore.ts | 45 +++---- ui-tui/src/app/useComposerState.ts | 8 +- ui-tui/src/app/useConfigSync.ts | 30 ++--- ui-tui/src/app/useInputHandlers.ts | 6 +- ui-tui/src/app/useLongRunToolCharms.ts | 7 +- ui-tui/src/app/useMainApp.ts | 13 +- ui-tui/src/app/useSubmission.ts | 31 ++--- ui-tui/src/banner.ts | 47 +++---- ui-tui/src/components/appChrome.tsx | 69 +++++----- ui-tui/src/components/appLayout.tsx | 27 ++-- ui-tui/src/components/appOverlays.tsx | 23 ++-- ui-tui/src/components/branding.tsx | 40 ++++-- ui-tui/src/components/maskedPrompt.tsx | 26 ++-- ui-tui/src/components/messageLine.tsx | 18 +-- ui-tui/src/components/modelPicker.tsx | 40 +++--- ui-tui/src/components/prompts.tsx | 48 ++++--- ui-tui/src/components/queuedMessages.tsx | 22 +-- ui-tui/src/components/sessionPicker.tsx | 34 ++--- ui-tui/src/components/textInput.tsx | 125 +++++++----------- ui-tui/src/components/themed.tsx | 35 ++--- ui-tui/src/config/env.ts | 5 +- ui-tui/src/content/fortunes.ts | 22 +-- ui-tui/src/domain/details.ts | 11 +- ui-tui/src/domain/messages.ts | 86 +++++------- ui-tui/src/domain/paths.ts | 3 +- ui-tui/src/domain/slash.ts | 30 +---- ui-tui/src/domain/viewport.ts | 6 +- ui-tui/src/gatewayClient.ts | 73 ++++------ ui-tui/src/hooks/useCompletion.ts | 12 +- ui-tui/src/hooks/useInputHistory.ts | 8 +- ui-tui/src/hooks/useQueue.ts | 14 +- ui-tui/src/hooks/useVirtualHistory.ts | 22 +-- ui-tui/src/lib/history.ts | 13 +- ui-tui/src/lib/messages.ts | 5 +- ui-tui/src/lib/osc52.ts | 5 +- ui-tui/src/lib/rpc.ts | 28 +--- ui-tui/src/lib/text.ts | 56 +++----- ui-tui/src/protocol/interpolation.ts | 6 +- ui-tui/src/types.ts | 9 +- 49 files changed, 527 insertions(+), 744 deletions(-) diff --git a/ui-tui/eslint.config.mjs b/ui-tui/eslint.config.mjs index 14a5d108d4..1b20c3244f 100644 --- a/ui-tui/eslint.config.mjs +++ b/ui-tui/eslint.config.mjs @@ -88,7 +88,8 @@ export default [ '@typescript-eslint/consistent-type-imports': 'off', 'no-constant-condition': 'off', 'no-empty': 'off', - 'no-redeclare': 'off' + 'no-redeclare': 'off', + 'react-hooks/exhaustive-deps': 'off' } }, { diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index bed421234f..c4cafcc4d7 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -235,7 +235,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren< // notify/scrollMutated are inline (no useCallback) but only close over // refs + imports — stable. Empty deps avoids rebuilding the handle on // every render (which re-registers the ref = churn). - // eslint-disable-next-line react-hooks/exhaustive-deps + [] ) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index ff7345e2be..46d34a70a4 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -35,9 +35,6 @@ const dropBgTask = (taskId: string) => return { ...state, bgTasks: next } }) -const statusToneFrom = (kind: string): 'error' | 'info' | 'warn' => - kind === 'error' ? 'error' : kind === 'warn' || kind === 'approval' ? 'warn' : 'info' - const pushUnique = (max: number) => (xs: T[], x: T): T[] => @@ -213,7 +210,10 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: if (turnController.lastStatusNote !== p.text) { turnController.lastStatusNote = p.text - turnController.pushActivity(p.text, statusToneFrom(p.kind)) + turnController.pushActivity( + p.text, + p.kind === 'error' ? 'error' : p.kind === 'warn' || p.kind === 'approval' ? 'warn' : 'info' + ) } restoreStatusAfter(4000) diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index de77075db1..87475341ae 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -7,10 +7,6 @@ import { findSlashCommand } from './slash/registry.js' import type { SlashRunCtx } from './slash/types.js' import { getUiState } from './uiStore.js' -const titleCase = (name: string) => name.charAt(0).toUpperCase() + name.slice(1) - -const isLong = (text: string) => text.length > 180 || text.split('\n').filter(Boolean).length > 2 - export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean { const { gw } = ctx.gateway const { catalog } = ctx.local @@ -79,8 +75,9 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b const body = r?.output || `/${parsed.name}: no output` const text = r?.warning ? `warning: ${r.warning}\n${body}` : body + const long = text.length > 180 || text.split('\n').filter(Boolean).length > 2 - isLong(text) ? page(text, titleCase(parsed.name)) : sys(text) + long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text) }) .catch(() => { gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid }) diff --git a/ui-tui/src/app/overlayStore.ts b/ui-tui/src/app/overlayStore.ts index de4adad62a..4b24f0daab 100644 --- a/ui-tui/src/app/overlayStore.ts +++ b/ui-tui/src/app/overlayStore.ts @@ -2,40 +2,25 @@ import { atom, computed } from 'nanostores' import type { OverlayState } from './interfaces.js' -function buildOverlayState(): OverlayState { - return { - approval: null, - clarify: null, - modelPicker: false, - pager: null, - picker: false, - secret: null, - sudo: null - } -} +const buildOverlayState = (): OverlayState => ({ + approval: null, + clarify: null, + modelPicker: false, + pager: null, + picker: false, + secret: null, + sudo: null +}) export const $overlayState = atom(buildOverlayState()) -export const $isBlocked = computed($overlayState, state => - Boolean( - state.approval || state.clarify || state.modelPicker || state.pager || state.picker || state.secret || state.sudo - ) +export const $isBlocked = computed($overlayState, ({ approval, clarify, modelPicker, pager, picker, secret, sudo }) => + Boolean(approval || clarify || modelPicker || pager || picker || secret || sudo) ) -export function getOverlayState() { - return $overlayState.get() -} +export const getOverlayState = () => $overlayState.get() -export function patchOverlayState(next: Partial | ((state: OverlayState) => OverlayState)) { - if (typeof next === 'function') { - $overlayState.set(next($overlayState.get())) +export const patchOverlayState = (next: Partial | ((state: OverlayState) => OverlayState)) => + $overlayState.set(typeof next === 'function' ? next($overlayState.get()) : { ...$overlayState.get(), ...next }) - return - } - - $overlayState.set({ ...$overlayState.get(), ...next }) -} - -export function resetOverlayState() { - $overlayState.set(buildOverlayState()) -} +export const resetOverlayState = () => $overlayState.set(buildOverlayState()) diff --git a/ui-tui/src/app/slash/commands/core.ts b/ui-tui/src/app/slash/commands/core.ts index 11c1107736..e0832c7a69 100644 --- a/ui-tui/src/app/slash/commands/core.ts +++ b/ui-tui/src/app/slash/commands/core.ts @@ -9,12 +9,12 @@ import { patchUiState } from '../../uiStore.js' import type { SlashCommand } from '../types.js' const flagFromArg = (arg: string, current: boolean): boolean | null => { - const mode = arg.trim().toLowerCase() - if (!arg) { return !current } + const mode = arg.trim().toLowerCase() + if (mode === 'on') { return true } @@ -46,14 +46,16 @@ export const coreCommands: SlashCommand[] = [ sections.push({ text: `${ctx.local.catalog.skillCount} skill commands available — /skills to browse` }) } - sections.push({ - rows: [ - ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], - ['/fortune [random|daily]', 'show a random or daily local fortune'] - ], - title: 'TUI' - }) - sections.push({ rows: HOTKEYS, title: 'Hotkeys' }) + sections.push( + { + rows: [ + ['/details [hidden|collapsed|expanded|cycle]', 'set agent detail visibility mode'], + ['/fortune [random|daily]', 'show a random or daily local fortune'] + ], + title: 'TUI' + }, + { rows: HOTKEYS, title: 'Hotkeys' } + ) ctx.transcript.panel(ctx.ui.theme.brand.helpHeader, sections) } diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index c1f6c6d83b..979e1f470a 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -9,7 +9,7 @@ export const opsCommands: SlashCommand[] = [ const [subcommand, ...names] = arg.trim().split(/\s+/).filter(Boolean) if (subcommand !== 'disable' && subcommand !== 'enable') { - return // py prints lists / show / usage + return } if (!names.length) { diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index 02a625604f..354d3c1975 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -109,7 +109,7 @@ export const sessionCommands: SlashCommand[] = [ name: 'personality', run: (arg, ctx) => { if (!arg) { - return // py handles listing + return } ctx.gateway.rpc('config.set', { key: 'personality', session_id: ctx.sid, value: arg }).then( @@ -200,11 +200,6 @@ export const sessionCommands: SlashCommand[] = [ } }, - // The four shims below call `config.set` directly because Python's `slash.exec` - // worker is a separate subprocess — it writes config but does NOT fire the - // live side-effects (`skin.changed` event, agent.reasoning_config, - // agent.verbose_logging, per-session yolo flip). Direct RPC does. - { help: 'switch theme skin (fires skin.changed)', name: 'skin', diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 3c7d1ee1d8..6a59d06384 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -5,14 +5,8 @@ import type { SlashCommand } from './types.js' export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands] -const byName = new Map() +const byName = new Map( + SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const)) +) -for (const cmd of SLASH_COMMANDS) { - byName.set(cmd.name, cmd) - - for (const alias of cmd.aliases ?? []) { - byName.set(alias, cmd) - } -} - -export const findSlashCommand = (name: string): SlashCommand | undefined => byName.get(name.toLowerCase()) +export const findSlashCommand = (name: string) => byName.get(name.toLowerCase()) diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index f4166ea8d1..d84633c947 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -2,6 +2,28 @@ import { atom } from 'nanostores' import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' +const buildTurnState = (): TurnState => ({ + activity: [], + reasoning: '', + reasoningActive: false, + reasoningStreaming: false, + reasoningTokens: 0, + streaming: '', + subagents: [], + toolTokens: 0, + tools: [], + turnTrail: [] +}) + +export const $turnState = atom(buildTurnState()) + +export const getTurnState = () => $turnState.get() + +export const patchTurnState = (next: Partial | ((state: TurnState) => TurnState)) => + $turnState.set(typeof next === 'function' ? next($turnState.get()) : { ...$turnState.get(), ...next }) + +export const resetTurnState = () => $turnState.set(buildTurnState()) + export interface TurnState { activity: ActivityItem[] reasoning: string @@ -14,34 +36,3 @@ export interface TurnState { tools: ActiveTool[] turnTrail: string[] } - -function buildTurnState(): TurnState { - return { - activity: [], - reasoning: '', - reasoningActive: false, - reasoningStreaming: false, - reasoningTokens: 0, - streaming: '', - subagents: [], - toolTokens: 0, - tools: [], - turnTrail: [] - } -} - -export const $turnState = atom(buildTurnState()) - -export const getTurnState = () => $turnState.get() - -export const patchTurnState = (next: Partial | ((state: TurnState) => TurnState)) => { - if (typeof next === 'function') { - $turnState.set(next($turnState.get())) - - return - } - - $turnState.set({ ...$turnState.get(), ...next }) -} - -export const resetTurnState = () => $turnState.set(buildTurnState()) diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index 868f2ba5e5..b7f5c20f4d 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -5,37 +5,24 @@ import { DEFAULT_THEME } from '../theme.js' import type { UiState } from './interfaces.js' -function buildUiState(): UiState { - return { - bgTasks: new Set(), - busy: false, - compact: false, - detailsMode: 'collapsed', - info: null, - sid: null, - status: 'summoning hermes…', - statusBar: true, - theme: DEFAULT_THEME, - usage: ZERO - } -} +const buildUiState = (): UiState => ({ + bgTasks: new Set(), + busy: false, + compact: false, + detailsMode: 'collapsed', + info: null, + sid: null, + status: 'summoning hermes…', + statusBar: true, + theme: DEFAULT_THEME, + usage: ZERO +}) export const $uiState = atom(buildUiState()) -export function getUiState() { - return $uiState.get() -} +export const getUiState = () => $uiState.get() -export function patchUiState(next: Partial | ((state: UiState) => UiState)) { - if (typeof next === 'function') { - $uiState.set(next($uiState.get())) +export const patchUiState = (next: Partial | ((state: UiState) => UiState)) => + $uiState.set(typeof next === 'function' ? next($uiState.get()) : { ...$uiState.get(), ...next }) - return - } - - $uiState.set({ ...$uiState.get(), ...next }) -} - -export function resetUiState() { - $uiState.set(buildUiState()) -} +export const resetUiState = () => $uiState.set(buildUiState()) diff --git a/ui-tui/src/app/useComposerState.ts b/ui-tui/src/app/useComposerState.ts index a4ccb1f016..14a40412c9 100644 --- a/ui-tui/src/app/useComposerState.ts +++ b/ui-tui/src/app/useComposerState.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -97,11 +97,7 @@ export function useComposerState({ gw, onClipboardPaste, submitRef }: UseCompose } } - try { - unlinkSync(file) - } catch { - /* noop */ - } + rmSync(file, { force: true }) }, [input, inputBuf, submitRef]) const actions = useMemo( diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 6a6edb4df2..3b40e72464 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -15,23 +15,16 @@ import { patchUiState } from './uiStore.js' const MTIME_POLL_MS = 5000 const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { - const display = cfg?.config?.display ?? {} + const d = cfg?.config?.display ?? {} - setBell(!!display.bell_on_complete) + setBell(!!d.bell_on_complete) patchUiState({ - compact: !!display.tui_compact, - detailsMode: resolveDetailsMode(display), - statusBar: display.tui_statusbar !== false + compact: !!d.tui_compact, + detailsMode: resolveDetailsMode(d), + statusBar: d.tui_statusbar !== false }) } -export interface UseConfigSyncOptions { - rpc: GatewayRpc - setBellOnComplete: (v: boolean) => void - setVoiceEnabled: (v: boolean) => void - sid: null | string -} - export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) { const mtimeRef = useRef(0) @@ -45,8 +38,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: mtimeRef.current = Number(r?.mtime ?? 0) }) rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rpc, sid]) + }, [rpc, setBellOnComplete, setVoiceEnabled, sid]) useEffect(() => { if (!sid) { @@ -79,6 +71,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: }, MTIME_POLL_MS) return () => clearInterval(id) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rpc, sid]) + }, [rpc, setBellOnComplete, sid]) +} + +export interface UseConfigSyncOptions { + rpc: GatewayRpc + setBellOnComplete: (v: boolean) => void + setVoiceEnabled: (v: boolean) => void + sid: null | string } diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 5359341504..71ecab6ac5 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -29,14 +29,14 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } } - const cancelOverlayFromCtrlC = (live: ReturnType) => { + const cancelOverlayFromCtrlC = () => { if (overlay.clarify) { return actions.answerClarify('') } if (overlay.approval) { return gateway - .rpc('approval.respond', { choice: 'deny', session_id: live.sid }) + .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid }) .then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied'))) } @@ -172,7 +172,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { } if (isCtrl(key, ch, 'c')) { - cancelOverlayFromCtrlC(live) + cancelOverlayFromCtrlC() } else if (key.escape && overlay.picker) { patchOverlayState({ picker: false }) } diff --git a/ui-tui/src/app/useLongRunToolCharms.ts b/ui-tui/src/app/useLongRunToolCharms.ts index 60a871c193..a65898db2b 100644 --- a/ui-tui/src/app/useLongRunToolCharms.ts +++ b/ui-tui/src/app/useLongRunToolCharms.ts @@ -47,10 +47,9 @@ export function useLongRunToolCharms(busy: boolean, tools: ActiveTool[]) { } slots.current.set(tool.id, { count: slot.count + 1, lastAt: now }) - - const sec = Math.round((now - tool.startedAt) / 1000) - - turnController.pushActivity(`${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${sec}s)`) + turnController.pushActivity( + `${pick(LONG_RUN_CHARMS)} (${toolTrailLabel(tool.name)} · ${Math.round((now - tool.startedAt) / 1000)}s)` + ) } } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index daa61bccfc..cfc471e0a9 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -170,18 +170,16 @@ export function useMainApp(gw: GatewayClient) { } const sel = selection.getState() as null | SelectionSnap + const top = s.getViewportTop() + const bottom = top + s.getViewportHeight() - 1 - const focusOutside = (top: number, bottom: number) => + if ( !sel?.anchor || !sel.focus || sel.anchor.row < top || sel.anchor.row > bottom || (!sel.isDragging && (sel.focus.row < top || sel.focus.row > bottom)) - - const top = s.getViewportTop() - const bottom = top + s.getViewportHeight() - 1 - - if (focusOutside(top, bottom)) { + ) { return s.scrollBy(delta) } @@ -197,12 +195,11 @@ export function useMainApp(gw: GatewayClient) { if (actual > 0) { selection.captureScrolledRows(top, top + actual - 1, 'above') - shift(-actual, top, bottom) } else { selection.captureScrolledRows(bottom + actual + 1, bottom, 'below') - shift(-actual, top, bottom) } + shift(-actual, top, bottom) s.scrollBy(delta) }, [selection] diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index fee2dd7272..f8a40f5a08 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -29,19 +29,6 @@ const expandSnips = (snips: PasteSnippet[]) => { const spliceMatches = (text: string, matches: RegExpMatchArray[], results: string[]) => matches.reduceRight((acc, m, i) => acc.slice(0, m.index!) + results[i] + acc.slice(m.index! + m[0].length), text) -export interface UseSubmissionOptions { - appendMessage: (msg: Msg) => void - composerActions: ComposerActions - composerRefs: ComposerRefs - composerState: ComposerState - gw: GatewayClient - maybeGoodVibes: (text: string) => void - setLastUserMsg: (value: string) => void - slashRef: MutableRefObject<(cmd: string) => boolean> - submitRef: MutableRefObject<(value: string) => void> - sys: (text: string) => void -} - export function useSubmission(opts: UseSubmissionOptions) { const { appendMessage, @@ -183,7 +170,6 @@ export function useSubmission(opts: UseSubmissionOptions) { return } - // Slash + shell run regardless of session state (each handles its own sid needs). if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) composerActions.pushHistory(full) @@ -201,7 +187,6 @@ export function useSubmission(opts: UseSubmissionOptions) { const live = getUiState() - // No session yet — queue the text and let the ready-flush effect send it. if (!live.sid) { composerActions.pushHistory(full) composerActions.enqueue(full) @@ -246,7 +231,7 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef, sys] + [appendMessage, composerActions, composerRefs, interpolate, send, sendQueued, shellExec, slashRef] ) const submit = useCallback( @@ -256,7 +241,6 @@ export function useSubmission(opts: UseSubmissionOptions) { if (row?.text) { const text = row.text.startsWith('/') && composerState.compReplace > 0 ? row.text.slice(1) : row.text - const next = value.slice(0, composerState.compReplace) + text if (next !== value) { @@ -304,3 +288,16 @@ export function useSubmission(opts: UseSubmissionOptions) { return { dispatchSubmission, send, sendQueued, shellExec, submit } } + +export interface UseSubmissionOptions { + appendMessage: (msg: Msg) => void + composerActions: ComposerActions + composerRefs: ComposerRefs + composerState: ComposerState + gw: GatewayClient + maybeGoodVibes: (text: string) => void + setLastUserMsg: (value: string) => void + slashRef: MutableRefObject<(cmd: string) => boolean> + submitRef: MutableRefObject<(value: string) => void> + sys: (text: string) => void +} diff --git a/ui-tui/src/banner.ts b/ui-tui/src/banner.ts index 6324cafe10..d048b7dac8 100644 --- a/ui-tui/src/banner.ts +++ b/ui-tui/src/banner.ts @@ -1,9 +1,5 @@ import type { ThemeColors } from './theme.js' -type Line = [string, string] - -// ── Rich markup parser ────────────────────────────────────────────── -// Parses Python Rich markup like "[bold #A3261F]text[/]" into Line[]. const RICH_RE = /\[(?:bold\s+)?(?:dim\s+)?(#(?:[0-9a-fA-F]{3,8}))\]([\s\S]*?)(\[\/\])/g export function parseRichMarkup(markup: string): Line[] { @@ -18,28 +14,29 @@ export function parseRichMarkup(markup: string): Line[] { continue } - let lastIndex = 0 - let matched = false - let m: RegExpExecArray | null + const matches = [...trimmed.matchAll(RICH_RE)] - RICH_RE.lastIndex = 0 + if (!matches.length) { + lines.push(['', trimmed]) - while ((m = RICH_RE.exec(trimmed)) !== null) { - matched = true - const before = trimmed.slice(lastIndex, m.index) + continue + } + + let cursor = 0 + + for (const m of matches) { + const before = trimmed.slice(cursor, m.index) if (before) { lines.push(['', before]) } lines.push([m[1]!, m[2]!]) - lastIndex = m.index + m[0].length + cursor = m.index! + m[0].length } - if (!matched) { - lines.push(['', trimmed]) - } else if (lastIndex < trimmed.length) { - lines.push(['', trimmed.slice(lastIndex)]) + if (cursor < trimmed.length) { + lines.push(['', trimmed.slice(cursor)]) } } @@ -76,10 +73,10 @@ 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] +const colorize = (art: string[], gradient: readonly number[], c: ThemeColors): Line[] => { + const p = [c.gold, c.amber, c.bronze, c.dim] - return art.map((text, i) => [palette[gradient[i]] ?? c.dim, text]) + return art.map((text, i) => [p[gradient[i]!] ?? c.dim, text]) } export const LOGO_WIDTH = 98 @@ -91,14 +88,6 @@ export const logo = (c: ThemeColors, customLogo?: string): Line[] => export const caduceus = (c: ThemeColors, customHero?: string): Line[] => customHero ? parseRichMarkup(customHero) : colorize(CADUCEUS_ART, CADUC_GRADIENT, c) -export function artWidth(lines: Line[]): number { - let max = 0 +export const artWidth = (lines: Line[]) => lines.reduce((m, [, t]) => Math.max(m, t.length), 0) - for (const [, text] of lines) { - if (text.length > max) { - max = text.length - } - } - - return max -} +type Line = [string, string] diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index f381057c0c..ed6f914c96 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -10,6 +10,7 @@ import type { Theme } from '../theme.js' import type { Msg, Usage } from '../types.js' const FACE_TICK_MS = 2500 +const HEART_COLORS = ['#ff5fa2', '#ff4d6d'] function FaceTicker({ color }: { color: string }) { const [tick, setTick] = useState(() => Math.floor(Math.random() * 1000)) @@ -76,10 +77,8 @@ export function GoodVibesHeart({ tick, t }: { tick: number; t: Theme }) { return } - const options = ['#ff5fa2', '#ff4d6d', t.color.amber] - const picked = options[Math.floor(Math.random() * options.length)]! - - setColor(picked) + const palette = [...HEART_COLORS, t.color.amber] + setColor(palette[Math.floor(Math.random() * palette.length)]!) setActive(true) const id = setTimeout(() => setActive(false), 650) @@ -102,19 +101,7 @@ export function StatusRule({ sessionStartedAt, voiceLabel, t -}: { - cwdLabel: string - cols: number - busy: boolean - status: string - statusColor: string - model: string - usage: Usage - bgCount: number - sessionStartedAt?: number | null - voiceLabel?: string - t: Theme -}) { +}: StatusRuleProps) { const pct = usage.context_percent const barColor = ctxBarColor(pct, t) @@ -124,7 +111,6 @@ export function StatusRule({ ? `${fmtK(usage.total)} tok` : '' - const pctLabel = pct != null ? `${pct}%` : '' const bar = usage.context_max ? ctxBar(pct) : '' const leftWidth = Math.max(12, cols - cwdLabel.length - 3) @@ -139,7 +125,7 @@ export function StatusRule({ {bar ? ( {' │ '} - [{bar}] {pctLabel} + [{bar}] {pct != null ? `${pct}%` : ''} ) : null} {sessionStartedAt ? ( @@ -152,6 +138,7 @@ export function StatusRule({ {bgCount > 0 ? │ {bgCount} bg : null} + {cwdLabel} @@ -174,17 +161,7 @@ export function FloatBox({ children, color }: { children: ReactNode; color: stri ) } -export function StickyPromptTracker({ - messages, - offsets, - scrollRef, - onChange -}: { - messages: readonly Msg[] - offsets: ArrayLike - scrollRef: RefObject - onChange: (text: string) => void -}) { +export function StickyPromptTracker({ messages, offsets, scrollRef, onChange }: StickyPromptTrackerProps) { useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), () => { @@ -210,13 +187,9 @@ export function StickyPromptTracker({ return null } -export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject; t: Theme }) { +export function TranscriptScrollbar({ scrollRef, t }: TranscriptScrollbarProps) { useSyncExternalStore( useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => {}), [scrollRef]), - // Quantize the scroll snapshot to the values the thumb actually renders - // with — thumbTop + thumbSize + viewport height. Streaming drives - // scrollHeight up by ~1 row at a time, but the quantized thumb usually - // doesn't move, so we skip thousands of render cycles mid-turn. () => { const s = scrollRef.current @@ -304,3 +277,29 @@ export function TranscriptScrollbar({ scrollRef, t }: { scrollRef: RefObject ) } + +interface StatusRuleProps { + bgCount: number + busy: boolean + cols: number + cwdLabel: string + model: string + sessionStartedAt?: number | null + status: string + statusColor: string + t: Theme + usage: Usage + voiceLabel?: string +} + +interface StickyPromptTrackerProps { + messages: readonly Msg[] + offsets: ArrayLike + onChange: (text: string) => void + scrollRef: RefObject +} + +interface TranscriptScrollbarProps { + scrollRef: RefObject + t: Theme +} diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 7d02503bd1..ca0edfea3f 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -24,20 +24,13 @@ const StreamingAssistant = memo(function StreamingAssistant({ detailsMode, progress, t -}: { - busy: boolean - cols: number - compact?: boolean - detailsMode: DetailsMode - progress: AppLayoutProgressProps - t: Theme -}) { +}: StreamingAssistantProps) { if (!progress.showProgressArea && !progress.showStreamingArea) { return null } return ( - + <> {progress.showProgressArea && ( )} - + ) }) @@ -79,15 +72,13 @@ const TranscriptPane = memo(function TranscriptPane({ }: Pick) { const ui = useStore($uiState) - const visibleHistory = transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end) - return ( <> {transcript.virtualHistory.topSpacer > 0 ? : null} - {visibleHistory.map(row => ( + {transcript.virtualRows.slice(transcript.virtualHistory.start, transcript.virtualHistory.end).map(row => ( {row.msg.kind === 'intro' ? ( @@ -234,6 +225,7 @@ const ComposerPane = memo(function ComposerPane({ placeholder={composer.empty ? PLACEHOLDER : ui.busy ? 'Ctrl+C to interrupt…' : ''} value={composer.input} /> + @@ -267,3 +259,12 @@ export const AppLayout = memo(function AppLayout({ ) }) + +interface StreamingAssistantProps { + busy: boolean + cols: number + compact?: boolean + detailsMode: DetailsMode + progress: AppLayoutProgressProps + t: Theme +} diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 5cdddd5046..509a990cdb 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -28,18 +28,17 @@ export function AppOverlays({ const overlay = useStore($overlayState) const ui = useStore($uiState) - if ( - !( - overlay.approval || - overlay.clarify || - overlay.modelPicker || - overlay.pager || - overlay.picker || - overlay.secret || - overlay.sudo || - completions.length - ) - ) { + const hasAny = + overlay.approval || + overlay.clarify || + overlay.modelPicker || + overlay.pager || + overlay.picker || + overlay.secret || + overlay.sudo || + completions.length + + if (!hasAny) { return null } diff --git a/ui-tui/src/components/branding.tsx b/ui-tui/src/components/branding.tsx index 859ff9bee7..fc019ac86f 100644 --- a/ui-tui/src/components/branding.tsx +++ b/ui-tui/src/components/branding.tsx @@ -20,11 +20,10 @@ export function ArtLines({ lines }: { lines: [string, string][] }) { export function Banner({ t }: { t: Theme }) { const cols = useStdout().stdout?.columns ?? 80 const logoLines = logo(t.color, t.bannerLogo || undefined) - const logoW = t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH return ( - {cols >= logoW ? ( + {cols >= (t.bannerLogo ? artWidth(logoLines) : LOGO_WIDTH) ? ( ) : ( @@ -37,18 +36,14 @@ export function Banner({ t }: { t: Theme }) { ) } -export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string | null; t: Theme }) { +export function SessionPanel({ info, sid, t }: SessionPanelProps) { const cols = useStdout().stdout?.columns ?? 100 const heroLines = caduceus(t.color, t.bannerHero || undefined) - const heroW = artWidth(heroLines) || CADUCEUS_WIDTH - const leftW = Math.min(heroW + 4, Math.floor(cols * 0.4)) + const leftW = Math.min((artWidth(heroLines) || CADUCEUS_WIDTH) + 4, Math.floor(cols * 0.4)) const wide = cols >= 90 && leftW + 40 < cols - // Keep an explicit gutter so right border never gets overwritten by long lines. const w = Math.max(20, wide ? cols - leftW - 14 : cols - 12) const lineBudget = Math.max(12, w - 2) - const cwd = info.cwd || process.cwd() const strip = (s: string) => (s.endsWith('_tools') ? s.slice(0, -6) : s) - const title = `${t.brand.name}${info.version ? ` v${info.version}` : ''}${info.release_date ? ` (${info.release_date})` : ''}` const truncLine = (pfx: string, items: string[]) => { let line = '' @@ -78,12 +73,14 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string Available {title} + {shown.map(([k, vs]) => ( {strip(k)}: {truncLine(strip(k) + ': ', vs)} ))} + {overflow > 0 && ( (and {overflow} {overflowLabel}) @@ -99,13 +96,16 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string + {info.model.split('/').pop()} · Nous Research + - {cwd} + {info.cwd || process.cwd()} + {sid && ( Session: @@ -114,21 +114,27 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string )} )} + - {title} + {t.brand.name} + {info.version ? ` v${info.version}` : ''} + {info.release_date ? ` (${info.release_date})` : ''} + {section('Tools', info.tools, 8, 'more toolsets…')} {section('Skills', info.skills)} + {flat(info.tools).length} tools{' · '} {flat(info.skills).length} skills {' · '} /help for commands + {typeof info.update_behind === 'number' && info.update_behind > 0 && ( ! {info.update_behind} {info.update_behind === 1 ? 'commit' : 'commits'} behind @@ -150,7 +156,7 @@ export function SessionPanel({ info, sid, t }: { info: SessionInfo; sid?: string ) } -export function Panel({ sections, t, title }: { sections: PanelSection[]; t: Theme; title: string }) { +export function Panel({ sections, t, title }: PanelProps) { return ( @@ -186,3 +192,15 @@ export function Panel({ sections, t, title }: { sections: PanelSection[]; t: The ) } + +interface PanelProps { + sections: PanelSection[] + t: Theme + title: string +} + +interface SessionPanelProps { + info: SessionInfo + sid?: string | null + t: Theme +} diff --git a/ui-tui/src/components/maskedPrompt.tsx b/ui-tui/src/components/maskedPrompt.tsx index f159cc681f..3739326bcc 100644 --- a/ui-tui/src/components/maskedPrompt.tsx +++ b/ui-tui/src/components/maskedPrompt.tsx @@ -5,21 +5,7 @@ import type { Theme } from '../theme.js' import { TextInput } from './textInput.js' -export function MaskedPrompt({ - cols = 80, - icon, - label, - onSubmit, - sub, - t -}: { - cols?: number - icon: string - label: string - onSubmit: (v: string) => void - sub?: string - t: Theme -}) { +export function MaskedPrompt({ cols = 80, icon, label, onSubmit, sub, t }: MaskedPromptProps) { const [value, setValue] = useState('') return ( @@ -27,6 +13,7 @@ export function MaskedPrompt({ {icon} {label} + {sub && {sub}} @@ -36,3 +23,12 @@ export function MaskedPrompt({ ) } + +interface MaskedPromptProps { + cols?: number + icon: string + label: string + onSubmit: (v: string) => void + sub?: string + t: Theme +} diff --git a/ui-tui/src/components/messageLine.tsx b/ui-tui/src/components/messageLine.tsx index 541971a01f..59db604e4b 100644 --- a/ui-tui/src/components/messageLine.tsx +++ b/ui-tui/src/components/messageLine.tsx @@ -18,14 +18,7 @@ export const MessageLine = memo(function MessageLine({ isStreaming = false, msg, t -}: { - cols: number - compact?: boolean - detailsMode?: DetailsMode - isStreaming?: boolean - msg: Msg - t: Theme -}) { +}: MessageLineProps) { if (msg.kind === 'trail' && msg.tools?.length) { return detailsMode === 'hidden' ? null : ( @@ -110,3 +103,12 @@ export const MessageLine = memo(function MessageLine({ ) }) + +interface MessageLineProps { + cols: number + compact?: boolean + detailsMode?: DetailsMode + isStreaming?: boolean + msg: Msg + t: Theme +} diff --git a/ui-tui/src/components/modelPicker.tsx b/ui-tui/src/components/modelPicker.tsx index b2891661a9..10a00cdf19 100644 --- a/ui-tui/src/components/modelPicker.tsx +++ b/ui-tui/src/components/modelPicker.tsx @@ -10,19 +10,13 @@ const VISIBLE = 12 const pageOffset = (count: number, sel: number) => Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), count - VISIBLE)) -export function ModelPicker({ - gw, - onCancel, - onSelect, - sessionId, - t -}: { - gw: GatewayClient - onCancel: () => void - onSelect: (value: string) => void - sessionId: string | null - t: Theme -}) { +const visibleItems = (items: string[], sel: number) => { + const off = pageOffset(items.length, sel) + + return { items: items.slice(off, off + VISIBLE), off } +} + +export function ModelPicker({ gw, onCancel, onSelect, sessionId, t }: ModelPickerProps) { const [providers, setProviders] = useState([]) const [currentModel, setCurrentModel] = useState('') const [err, setErr] = useState('') @@ -66,12 +60,6 @@ export function ModelPicker({ const provider = providers[providerIdx] const models = provider?.models ?? [] - const visibleItems = (items: string[], sel: number) => { - const off = pageOffset(items.length, sel) - - return { items: items.slice(off, off + VISIBLE), off } - } - useInput((ch, key) => { if (key.escape) { if (stage === 'model') { @@ -182,9 +170,11 @@ export function ModelPicker({ Select Provider + Current model: {currentModel || '(unknown)'} {provider?.warning ? warning: {provider.warning} : null} {off > 0 && ↑ {off} more} + {items.map((row, i) => { const idx = off + i @@ -195,6 +185,7 @@ export function ModelPicker({ ) })} + {off + VISIBLE < rows.length && ↓ {rows.length - off - VISIBLE} more} persist: {persistGlobal ? 'global' : 'session'} · g toggle ↑/↓ select · Enter choose · 1-9,0 quick · Esc cancel @@ -209,10 +200,12 @@ export function ModelPicker({ Select Model + {provider?.name || '(unknown provider)'} {!models.length ? no models listed for this provider : null} {provider?.warning ? warning: {provider.warning} : null} {off > 0 && ↑ {off} more} + {items.map((row, i) => { const idx = off + i @@ -223,6 +216,7 @@ export function ModelPicker({ ) })} + {off + VISIBLE < models.length && ↓ {models.length - off - VISIBLE} more} persist: {persistGlobal ? 'global' : 'session'} · g toggle @@ -231,3 +225,11 @@ export function ModelPicker({ ) } + +interface ModelPickerProps { + gw: GatewayClient + onCancel: () => void + onSelect: (value: string) => void + sessionId: string | null + t: Theme +} diff --git a/ui-tui/src/components/prompts.tsx b/ui-tui/src/components/prompts.tsx index 3dd8a9d756..e53c5be448 100644 --- a/ui-tui/src/components/prompts.tsx +++ b/ui-tui/src/components/prompts.tsx @@ -6,10 +6,11 @@ import type { ApprovalReq, ClarifyReq } from '../types.js' import { TextInput } from './textInput.js' -export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => void; req: ApprovalReq; t: Theme }) { +const OPTS = ['once', 'session', 'always', 'deny'] as const +const LABELS = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const + +export function ApprovalPrompt({ onChoice, req, t }: ApprovalPromptProps) { const [sel, setSel] = useState(3) - const opts = ['once', 'session', 'always', 'deny'] as const - const labels = { always: 'Always allow', deny: 'Deny', once: 'Allow once', session: 'Allow this session' } as const useInput((ch, key) => { if (key.upArrow && sel > 0) { @@ -21,7 +22,7 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => } if (key.return) { - onChoice(opts[sel]!) + onChoice(OPTS[sel]!) } if (ch === 'o') { @@ -46,34 +47,25 @@ export function ApprovalPrompt({ onChoice, req, t }: { onChoice: (s: string) => ! DANGEROUS COMMAND: {req.description} + {req.command} - {opts.map((o, i) => ( + + {OPTS.map((o, i) => ( {sel === i ? '▸ ' : ' '} - [{o[0]}] {labels[o]} + [{o[0]}] {LABELS[o]} ))} + ↑/↓ select · Enter confirm · o/s/a/d quick pick ) } -export function ClarifyPrompt({ - cols = 80, - onAnswer, - onCancel, - req, - t -}: { - cols?: number - onAnswer: (s: string) => void - onCancel: () => void - req: ClarifyReq - t: Theme -}) { +export function ClarifyPrompt({ cols = 80, onAnswer, onCancel, req, t }: ClarifyPromptProps) { const [sel, setSel] = useState(0) const [custom, setCustom] = useState('') const [typing, setTyping] = useState(false) @@ -117,8 +109,6 @@ export function ClarifyPrompt({ }) if (typing || !choices.length) { - const hint = choices.length ? 'Enter send · Esc back · Ctrl+C cancel' : 'Enter send · Esc cancel · Ctrl+C cancel' - return ( {heading} @@ -128,7 +118,7 @@ export function ClarifyPrompt({ - {hint} + Enter send · Esc {choices.length ? 'back' : 'cancel'} · Ctrl+C cancel ) } @@ -150,3 +140,17 @@ export function ClarifyPrompt({ ) } + +interface ApprovalPromptProps { + onChoice: (s: string) => void + req: ApprovalReq + t: Theme +} + +interface ClarifyPromptProps { + cols?: number + onAnswer: (s: string) => void + onCancel: () => void + req: ClarifyReq + t: Theme +} diff --git a/ui-tui/src/components/queuedMessages.tsx b/ui-tui/src/components/queuedMessages.tsx index 7688e6148c..ab9c42c551 100644 --- a/ui-tui/src/components/queuedMessages.tsx +++ b/ui-tui/src/components/queuedMessages.tsx @@ -14,17 +14,7 @@ export function getQueueWindow(queueLen: number, queueEditIdx: number | null) { return { end, showLead: start > 0, showTail: end < queueLen, start } } -export function QueuedMessages({ - cols, - queueEditIdx, - queued, - t -}: { - cols: number - queueEditIdx: number | null - queued: string[] - t: Theme -}) { +export function QueuedMessages({ cols, queueEditIdx, queued, t }: QueuedMessagesProps) { if (!queued.length) { return null } @@ -36,12 +26,14 @@ export function QueuedMessages({ queued ({queued.length}){queueEditIdx !== null ? ` · editing ${queueEditIdx + 1}` : ''} + {q.showLead && ( {' '} … )} + {queued.slice(q.start, q.end).map((item, i) => { const idx = q.start + i const active = queueEditIdx === idx @@ -52,6 +44,7 @@ export function QueuedMessages({ ) })} + {q.showTail && ( {' '}…and {queued.length - q.end} more @@ -60,3 +53,10 @@ export function QueuedMessages({ ) } + +interface QueuedMessagesProps { + cols: number + queueEditIdx: number | null + queued: string[] + t: Theme +} diff --git a/ui-tui/src/components/sessionPicker.tsx b/ui-tui/src/components/sessionPicker.tsx index 5aeb238782..905fa707e3 100644 --- a/ui-tui/src/components/sessionPicker.tsx +++ b/ui-tui/src/components/sessionPicker.tsx @@ -6,7 +6,9 @@ import type { SessionListItem, SessionListResponse } from '../gatewayTypes.js' import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import type { Theme } from '../theme.js' -function age(ts: number): string { +const VISIBLE = 15 + +const age = (ts: number) => { const d = (Date.now() / 1000 - ts) / 86400 if (d < 1) { @@ -20,19 +22,7 @@ function age(ts: number): string { return `${Math.floor(d)}d ago` } -const VISIBLE = 15 - -export function SessionPicker({ - gw, - onCancel, - onSelect, - t -}: { - gw: GatewayClient - onCancel: () => void - onSelect: (id: string) => void - t: Theme -}) { +export function SessionPicker({ gw, onCancel, onSelect, t }: SessionPickerProps) { const [items, setItems] = useState([]) const [err, setErr] = useState('') const [sel, setSel] = useState(0) @@ -107,36 +97,48 @@ export function SessionPicker({ } const off = Math.max(0, Math.min(sel - Math.floor(VISIBLE / 2), items.length - VISIBLE)) - const visible = items.slice(off, off + VISIBLE) return ( Resume Session + {off > 0 && ↑ {off} more} - {visible.map((s, vi) => { + + {items.slice(off, off + VISIBLE).map((s, vi) => { const i = off + vi return ( {sel === i ? '▸ ' : ' '} + {String(i + 1).padStart(2)}. [{s.id}] + ({s.message_count} msgs, {age(s.started_at)}, {s.source || 'tui'}) + {s.title || s.preview || '(untitled)'} ) })} + {off + VISIBLE < items.length && ↓ {items.length - off - VISIBLE} more} ↑/↓ select · Enter resume · 1-9 quick · Esc cancel ) } + +interface SessionPickerProps { + gw: GatewayClient + onCancel: () => void + onSelect: (id: string) => void + t: Theme +} diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index fbbd37ccbe..4825373762 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -11,8 +11,6 @@ type InkExt = typeof Ink & { const ink = Ink as unknown as InkExt const { Box, Text, useStdin, useInput, stringWidth, useDeclaredCursor, useTerminalFocus } = ink -// ── ANSI escapes ───────────────────────────────────────────────────── - const ESC = '\x1b' const INV = `${ESC}[7m` const INV_OFF = `${ESC}[27m` @@ -25,8 +23,6 @@ const BRACKET_PASTE = new RegExp(`${ESC}?\\[20[01]~`, 'g') const invert = (s: string) => INV + s + INV_OFF const dim = (s: string) => DIM + s + DIM_OFF -// ── Grapheme segmenter (lazy singleton) ────────────────────────────── - let _seg: Intl.Segmenter | null = null const seg = () => (_seg ??= new Intl.Segmenter(undefined, { granularity: 'grapheme' })) const STOP_CACHE_MAX = 32 @@ -106,8 +102,6 @@ function nextPos(s: string, p: number) { return s.length } -// ── Word movement ──────────────────────────────────────────────────── - function wordLeft(s: string, p: number) { let i = snapPos(s, p) - 1 @@ -136,8 +130,6 @@ function wordRight(s: string, p: number) { return i } -// ── Cursor layout (line/column from offset + terminal width) ───────── - function cursorLayout(value: string, cursor: number, cols: number) { const pos = Math.max(0, Math.min(cursor, value.length)) const w = Math.max(1, cols - 1) @@ -226,8 +218,6 @@ function offsetFromPosition(value: string, row: number, col: number, cols: numbe return lastOffset } -// ── Render value with inverse-video cursor ─────────────────────────── - function renderWithCursor(value: string, cursor: number) { const pos = Math.max(0, Math.min(cursor, value.length)) @@ -250,8 +240,6 @@ function renderWithCursor(value: string, cursor: number) { return done ? out : out + invert(' ') } -// ── Forward-delete detection hook ──────────────────────────────────── - function useFwdDelete(active: boolean) { const ref = useRef(false) const { inputEmitter: ee } = useStdin() @@ -275,29 +263,6 @@ function useFwdDelete(active: boolean) { return ref } -// ── Types ──────────────────────────────────────────────────────────── - -export interface PasteEvent { - bracketed?: boolean - cursor: number - hotkey?: boolean - text: string - value: string -} - -interface Props { - columns?: number - value: string - onChange: (v: string) => void - onSubmit?: (v: string) => void - onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null - mask?: string - placeholder?: string - focus?: boolean -} - -// ── Component ──────────────────────────────────────────────────────── - export function TextInput({ columns = 80, value, @@ -307,7 +272,7 @@ export function TextInput({ mask, placeholder = '', focus = true -}: Props) { +}: TextInputProps) { const [cur, setCur] = useState(value.length) const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() @@ -331,8 +296,6 @@ export function TextInput({ const raw = self.current ? vRef.current : value const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw - // ── Cursor declaration ─────────────────────────────────────────── - const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display]) const boxRef = useDeclaredCursor({ @@ -353,18 +316,6 @@ export function TextInput({ return renderWithCursor(display, cur) }, [cur, display, focus, placeholder]) - const clickCursor = (e: { localRow?: number; localCol?: number }) => { - if (!focus) { - return - } - - const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) - setCur(next) - curRef.current = next - } - - // ── Sync external value changes ────────────────────────────────── - useEffect(() => { if (self.current) { self.current = false @@ -386,8 +337,6 @@ export function TextInput({ [] ) - // ── Buffer ops (synchronous, ref-based) ────────────────────────── - const commit = (next: string, nextCur: number, track = true) => { const prev = vRef.current const c = snapPos(next, nextCur) @@ -450,18 +399,14 @@ export function TextInput({ const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c) - // ── Input handler ──────────────────────────────────────────────── - useInput( (inp: string, k: Key, event: InputEvent) => { - const raw = event.keypress.raw - const metaPaste = raw === '\x1bv' || raw === '\x1bV' + const eventRaw = event.keypress.raw - if (metaPaste) { + if (eventRaw === '\x1bv' || eventRaw === '\x1bV') { return void emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) } - // Delegated to App if ( k.upArrow || k.downArrow || @@ -487,7 +432,6 @@ export function TextInput({ let v = vRef.current const mod = k.ctrl || k.meta - // Undo / redo if (k.ctrl && inp === 'z') { return swap(undo, redo) } @@ -496,7 +440,6 @@ export function TextInput({ return swap(redo, undo) } - // Navigation if (k.home || (k.ctrl && inp === 'a')) { c = 0 } else if (k.end || (k.ctrl && inp === 'e')) { @@ -509,10 +452,7 @@ export function TextInput({ c = wordLeft(v, c) } else if (k.meta && inp === 'f') { c = wordRight(v, c) - } - - // Deletion - else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { + } else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -538,31 +478,28 @@ export function TextInput({ c = 0 } else if (k.ctrl && inp === 'k') { v = v.slice(0, c) - } - - // Text insertion / paste buffering - else if (inp.length > 0) { + } else if (inp.length > 0) { const bracketed = inp.includes('[200~') - const raw = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') - if (bracketed && emitPaste({ bracketed: true, cursor: c, text: raw, value: v })) { + if (bracketed && emitPaste({ bracketed: true, cursor: c, text, value: v })) { return } - if (!raw) { + if (!text) { return } - if (raw === '\n') { + if (text === '\n') { return commit(ins(v, c, '\n'), c + 1) } - if (raw.length > 1 || raw.includes('\n')) { + if (text.length > 1 || text.includes('\n')) { if (!pasteBuf.current) { pastePos.current = c } - pasteBuf.current += raw + pasteBuf.current += text if (pasteTimer.current) { clearTimeout(pasteTimer.current) @@ -573,9 +510,9 @@ export function TextInput({ return } - if (PRINTABLE.test(raw)) { - v = v.slice(0, c) + raw + v.slice(c) - c += raw.length + if (PRINTABLE.test(text)) { + v = v.slice(0, c) + text + v.slice(c) + c += text.length } else { return } @@ -588,11 +525,39 @@ export function TextInput({ { isActive: focus } ) - // ── Render ─────────────────────────────────────────────────────── - return ( - + { + if (!focus) { + return + } + + const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) + setCur(next) + curRef.current = next + }} + ref={boxRef} + > {rendered} ) } + +export interface PasteEvent { + bracketed?: boolean + cursor: number + hotkey?: boolean + text: string + value: string +} + +interface TextInputProps { + columns?: number + focus?: boolean + mask?: string + onChange: (v: string) => void + onPaste?: (e: PasteEvent) => { cursor: number; value: string } | null + onSubmit?: (v: string) => void + placeholder?: string + value: string +} diff --git a/ui-tui/src/components/themed.tsx b/ui-tui/src/components/themed.tsx index b007d78aa0..25fb43b44c 100644 --- a/ui-tui/src/components/themed.tsx +++ b/ui-tui/src/components/themed.tsx @@ -5,6 +5,16 @@ import type { ReactNode } from 'react' import { $uiState } from '../app/uiStore.js' import type { ThemeColors } from '../theme.js' +export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) { + const { theme } = useStore($uiState) + + return ( + + {children} + + ) +} + export type ThemeColor = keyof ThemeColors export interface FgProps { @@ -18,28 +28,3 @@ export interface FgProps { underline?: boolean wrap?: 'end' | 'middle' | 'truncate' | 'truncate-end' | 'truncate-middle' | 'truncate-start' | 'wrap' | 'wrap-trim' } - -/** - * Theme-aware text. `literal` wins; otherwise `c` is a palette key. - * - * hi // amber - * // dim cornsilk - * x // raw hex - */ -export function Fg({ bold, c, children, dim, italic, literal, strikethrough, underline, wrap }: FgProps) { - const { theme } = useStore($uiState) - - return ( - - {children} - - ) -} diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 91da2121d3..3a476d6bc5 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -1,5 +1,2 @@ export const STARTUP_RESUME_ID = (process.env.HERMES_TUI_RESUME ?? '').trim() - -export const MOUSE_TRACKING = !/^(1|true|yes|on)$/.test( - (process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim().toLowerCase() -) +export const MOUSE_TRACKING = !/^(?:1|true|yes|on)$/i.test((process.env.HERMES_TUI_DISABLE_MOUSE ?? '').trim()) diff --git a/ui-tui/src/content/fortunes.ts b/ui-tui/src/content/fortunes.ts index cd88dc4786..87943f9f42 100644 --- a/ui-tui/src/content/fortunes.ts +++ b/ui-tui/src/content/fortunes.ts @@ -11,30 +11,20 @@ const FORTUNES = [ 'your instincts are correctly suspicious of that one branch' ] -const LEGENDARY_FORTUNES = [ +const LEGENDARY = [ 'legendary drop: one-line fix, first try', 'legendary drop: every flaky test passes cleanly', 'legendary drop: your diff teaches by itself' ] -const hash = (input: string) => { - let out = 2166136261 +const hash = (s: string) => [...s].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) >>> 0 - for (let i = 0; i < input.length; i++) { - out ^= input.charCodeAt(i) - out = Math.imul(out, 16777619) - } +const fromScore = (n: number) => { + const rare = n % 20 === 0 + const bag = rare ? LEGENDARY : FORTUNES - return out >>> 0 -} - -const fromScore = (score: number) => { - const rare = score % 20 === 0 - const bag = rare ? LEGENDARY_FORTUNES : FORTUNES - - return `${rare ? '🌟' : '🔮'} ${bag[score % bag.length]}` + return `${rare ? '🌟' : '🔮'} ${bag[n % bag.length]}` } export const randomFortune = () => fromScore(Math.floor(Math.random() * 0x7fffffff)) - export const dailyFortune = (seed: null | string) => fromScore(hash(`${seed || 'anon'}|${new Date().toDateString()}`)) diff --git a/ui-tui/src/domain/details.ts b/ui-tui/src/domain/details.ts index 84c2cd80e7..fa01092f5d 100644 --- a/ui-tui/src/domain/details.ts +++ b/ui-tui/src/domain/details.ts @@ -1,6 +1,6 @@ import type { DetailsMode } from '../types.js' -const DETAILS_MODES: DetailsMode[] = ['hidden', 'collapsed', 'expanded'] +const MODES = ['hidden', 'collapsed', 'expanded'] as const const THINKING_FALLBACK: Record = { collapsed: 'collapsed', @@ -11,12 +11,10 @@ const THINKING_FALLBACK: Record = { export const parseDetailsMode = (v: unknown): DetailsMode | null => { const s = typeof v === 'string' ? v.trim().toLowerCase() : '' - return DETAILS_MODES.includes(s as DetailsMode) ? (s as DetailsMode) : null + return MODES.find(m => m === s) ?? null } -export const resolveDetailsMode = ( - d: { details_mode?: unknown; thinking_mode?: unknown } | null | undefined -): DetailsMode => +export const resolveDetailsMode = (d?: { details_mode?: unknown; thinking_mode?: unknown } | null): DetailsMode => parseDetailsMode(d?.details_mode) ?? THINKING_FALLBACK[ String(d?.thinking_mode ?? '') @@ -25,5 +23,4 @@ export const resolveDetailsMode = ( ] ?? 'collapsed' -export const nextDetailsMode = (m: DetailsMode): DetailsMode => - DETAILS_MODES[(DETAILS_MODES.indexOf(m) + 1) % DETAILS_MODES.length]! +export const nextDetailsMode = (m: DetailsMode): DetailsMode => MODES[(MODES.indexOf(m) + 1) % MODES.length]! diff --git a/ui-tui/src/domain/messages.ts b/ui-tui/src/domain/messages.ts index 2b7f4a513e..34b072f01a 100644 --- a/ui-tui/src/domain/messages.ts +++ b/ui-tui/src/domain/messages.ts @@ -2,30 +2,17 @@ import { LONG_MSG } from '../config/limits.js' import { buildToolTrailLine, fmtK } from '../lib/text.js' import type { Msg, SessionInfo } from '../types.js' -interface ImageMeta { - height?: number - token_estimate?: number - width?: number -} - -interface TranscriptRow { - context?: string - name?: string - role?: string - text?: string -} - export const introMsg = (info: SessionInfo): Msg => ({ info, kind: 'intro', role: 'system', text: '' }) -export const imageTokenMeta = (info: ImageMeta | null | undefined) => - [ - info?.width && info.height ? `${info.width}x${info.height}` : '', - typeof info?.token_estimate === 'number' && info.token_estimate > 0 ? `~${fmtK(info.token_estimate)} tok` : '' - ] +export const imageTokenMeta = (info?: ImageMeta | null) => { + const { width, height, token_estimate: t } = info ?? {} + + return [width && height ? `${width}x${height}` : '', (t ?? 0) > 0 ? `~${fmtK(t!)} tok` : ''] .filter(Boolean) .join(' · ') +} -export const userDisplay = (text: string): string => { +export const userDisplay = (text: string) => { if (text.length <= LONG_MSG) { return text } @@ -42,8 +29,8 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { return [] } - const result: Msg[] = [] - let pendingTools: string[] = [] + const out: Msg[] = [] + let pending: string[] = [] for (const row of rows) { if (!row || typeof row !== 'object') { @@ -53,7 +40,7 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { const { context, name, role, text } = row as TranscriptRow if (role === 'tool') { - pendingTools.push(buildToolTrailLine(name ?? 'tool', context ?? '')) + pending.push(buildToolTrailLine(name ?? 'tool', context ?? '')) continue } @@ -63,40 +50,35 @@ export const toTranscriptMessages = (rows: unknown): Msg[] => { } if (role === 'assistant') { - const msg: Msg = { role, text } - - if (pendingTools.length) { - msg.tools = pendingTools - pendingTools = [] - } - - result.push(msg) - - continue - } - - if (role === 'user' || role === 'system') { - pendingTools = [] - result.push({ role, text }) + out.push({ role, text, ...(pending.length && { tools: pending }) }) + pending = [] + } else if (role === 'user' || role === 'system') { + out.push({ role, text }) + pending = [] } } - return result + return out } -export function fmtDuration(ms: number) { - const total = Math.max(0, Math.floor(ms / 1000)) - const hours = Math.floor(total / 3600) - const mins = Math.floor((total % 3600) / 60) - const secs = total % 60 +export const fmtDuration = (ms: number) => { + const t = Math.max(0, Math.floor(ms / 1000)) + const h = Math.floor(t / 3600) + const m = Math.floor((t % 3600) / 60) + const s = t % 60 - if (hours > 0) { - return `${hours}h ${mins}m` - } - - if (mins > 0) { - return `${mins}m ${secs}s` - } - - return `${secs}s` + return h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${s}s` : `${s}s` +} + +interface ImageMeta { + height?: number + token_estimate?: number + width?: number +} + +interface TranscriptRow { + context?: string + name?: string + role?: string + text?: string } diff --git a/ui-tui/src/domain/paths.ts b/ui-tui/src/domain/paths.ts index 120a71d79b..78daff170a 100644 --- a/ui-tui/src/domain/paths.ts +++ b/ui-tui/src/domain/paths.ts @@ -1,5 +1,6 @@ export const shortCwd = (cwd: string, max = 28) => { - const p = process.env.HOME && cwd.startsWith(process.env.HOME) ? `~${cwd.slice(process.env.HOME.length)}` : cwd + const h = process.env.HOME + const p = h && cwd.startsWith(h) ? `~${cwd.slice(h.length)}` : cwd return p.length <= max ? p : `…${p.slice(-(max - 1))}` } diff --git a/ui-tui/src/domain/slash.ts b/ui-tui/src/domain/slash.ts index fd5b327d78..1fc8082ba5 100644 --- a/ui-tui/src/domain/slash.ts +++ b/ui-tui/src/domain/slash.ts @@ -1,25 +1,7 @@ -export interface ParsedSlashCommand { - arg: string - cmd: string - name: string -} - -export const looksLikeSlashCommand = (text: string) => { - if (!text.startsWith('/')) { - return false - } - - const first = text.split(/\s+/, 1)[0] || '' - - return !first.slice(1).includes('/') -} - -export const parseSlashCommand = (cmd: string): ParsedSlashCommand => { - const [rawName = '', ...rest] = cmd.slice(1).split(/\s+/) - - return { - arg: rest.join(' '), - cmd, - name: rawName.toLowerCase() - } +export const looksLikeSlashCommand = (text: string) => /^\/[^\s/]*(?:\s|$)/.test(text) + +export const parseSlashCommand = (cmd: string) => { + const [name = '', ...rest] = cmd.slice(1).split(/\s+/) + + return { arg: rest.join(' '), cmd, name: name.toLowerCase() } } diff --git a/ui-tui/src/domain/viewport.ts b/ui-tui/src/domain/viewport.ts index 3dccc3177f..788f94269e 100644 --- a/ui-tui/src/domain/viewport.ts +++ b/ui-tui/src/domain/viewport.ts @@ -26,17 +26,13 @@ export const stickyPromptFromViewport = ( } const first = Math.max(0, Math.min(messages.length - 1, upperBound(offsets, top) - 1)) - const aboveViewport = (i: number) => (offsets[i] ?? 0) + 1 < top - // Walk backward from the first visible row. The nearest user message wins: - // if it's still on screen, no sticky is needed; if it's already scrolled - // above the top, its text becomes the floating breadcrumb. for (let i = first; i >= 0; i--) { if (messages[i]?.role !== 'user') { continue } - return aboveViewport(i) ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : '' + return (offsets[i] ?? 0) + 1 < top ? userDisplay(messages[i]!.text.trim()).replace(/\s+/g, ' ').trim() : '' } return '' diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index ad1fed31a8..3d5f89eb8c 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -12,49 +12,34 @@ const STARTUP_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_TUI_STARTU const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_TUI_RPC_TIMEOUT_MS ?? '120000', 10) || 120000) const resolvePython = (root: string) => { - const configured = process.env.HERMES_PYTHON?.trim() + const configured = process.env.HERMES_PYTHON?.trim() || process.env.PYTHON?.trim() if (configured) { return configured } - const envPython = process.env.PYTHON?.trim() - - if (envPython) { - return envPython - } - const venv = process.env.VIRTUAL_ENV?.trim() - const candidates = [ - venv ? resolve(venv, 'bin/python') : '', - venv ? resolve(venv, 'Scripts/python.exe') : '', + const hit = [ + venv && resolve(venv, 'bin/python'), + venv && resolve(venv, 'Scripts/python.exe'), resolve(root, '.venv/bin/python'), resolve(root, '.venv/bin/python3'), resolve(root, 'venv/bin/python'), resolve(root, 'venv/bin/python3') - ].filter(Boolean) + ].find(p => p && existsSync(p)) - const hit = candidates.find(path => existsSync(path)) - - if (hit) { - return hit - } - - return process.platform === 'win32' ? 'python' : 'python3' + return hit || (process.platform === 'win32' ? 'python' : 'python3') } -const asGatewayEvent = (value: unknown): GatewayEvent | null => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - - return typeof (value as { type?: unknown }).type === 'string' ? (value as GatewayEvent) : null -} +const asGatewayEvent = (value: unknown): GatewayEvent | null => + value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string' + ? (value as GatewayEvent) + : null interface Pending { - resolve: (v: unknown) => void reject: (e: Error) => void + resolve: (v: unknown) => void } export class GatewayClient extends EventEmitter { @@ -81,9 +66,7 @@ export class GatewayClient extends EventEmitter { } if (this.subscribed) { - this.emit('event', ev) - - return + return void this.emit('event', ev) } this.bufferedEvents.push(ev) @@ -94,8 +77,9 @@ export class GatewayClient extends EventEmitter { const python = resolvePython(root) const cwd = process.env.HERMES_CWD || root const env = { ...process.env } - const pyPath = (env.PYTHONPATH ?? '').trim() + const pyPath = env.PYTHONPATH?.trim() env.PYTHONPATH = pyPath ? `${root}${delimiter}${pyPath}` : root + this.ready = false this.bufferedEvents = [] this.pendingExit = undefined @@ -121,11 +105,7 @@ export class GatewayClient extends EventEmitter { this.publish({ type: 'gateway.start_timeout', payload: { cwd, python } }) }, STARTUP_TIMEOUT_MS) - this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { - cwd, - env, - stdio: ['pipe', 'pipe', 'pipe'] - }) + this.proc = spawn(python, ['-m', 'tui_gateway.entry'], { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }) this.stdoutRl = createInterface({ input: this.proc.stdout! }) this.stdoutRl.on('line', raw => { @@ -133,8 +113,9 @@ export class GatewayClient extends EventEmitter { this.dispatch(JSON.parse(raw)) } catch { const preview = raw.trim().slice(0, MAX_LOG_PREVIEW) || '(empty line)' + this.pushLog(`[protocol] malformed stdout: ${preview}`) - this.publish({ type: 'gateway.protocol_error', payload: { preview } } satisfies GatewayEvent) + this.publish({ type: 'gateway.protocol_error', payload: { preview } }) } }) @@ -147,13 +128,13 @@ export class GatewayClient extends EventEmitter { } this.pushLog(line) - this.publish({ type: 'gateway.stderr', payload: { line } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line } }) }) this.proc.on('error', err => { this.pushLog(`[spawn] ${err.message}`) this.rejectPending(new Error(`gateway error: ${err.message}`)) - this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } } satisfies GatewayEvent) + this.publish({ type: 'gateway.stderr', payload: { line: `[spawn] ${err.message}` } }) }) this.proc.on('exit', code => { @@ -181,6 +162,7 @@ export class GatewayClient extends EventEmitter { if (msg.error) { const err = msg.error as { message?: unknown } | null | undefined + p.reject(new Error(typeof err?.message === 'string' ? err.message : 'request failed')) } else { p.resolve(msg.result) @@ -199,30 +181,29 @@ export class GatewayClient extends EventEmitter { } private pushLog(line: string) { - this.logs.push(line) - - if (this.logs.length > MAX_GATEWAY_LOG_LINES) { + if (this.logs.push(line) > MAX_GATEWAY_LOG_LINES) { this.logs.splice(0, this.logs.length - MAX_GATEWAY_LOG_LINES) } } private rejectPending(err: Error) { - for (const [id, pending] of this.pending) { - this.pending.delete(id) - pending.reject(err) + for (const p of this.pending.values()) { + p.reject(err) } + + this.pending.clear() } drain() { this.subscribed = true - const pending = this.bufferedEvents.splice(0) - for (const ev of pending) { + for (const ev of this.bufferedEvents.splice(0)) { this.emit('event', ev) } if (this.pendingExit !== undefined) { const code = this.pendingExit + this.pendingExit = undefined this.emit('exit', code) } diff --git a/ui-tui/src/hooks/useCompletion.ts b/ui-tui/src/hooks/useCompletion.ts index c6ba28c808..5b0c2659ed 100644 --- a/ui-tui/src/hooks/useCompletion.ts +++ b/ui-tui/src/hooks/useCompletion.ts @@ -34,7 +34,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient ref.current = input const isSlash = input.startsWith('/') - const pathWord = !isSlash ? (input.match(TAB_PATH_RE)?.[1] ?? null) : null + const pathWord = isSlash ? null : (input.match(TAB_PATH_RE)?.[1] ?? null) if (!isSlash && !pathWord) { clear() @@ -42,6 +42,8 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient return } + const pathReplace = input.length - (pathWord?.length ?? 0) + const t = setTimeout(() => { if (ref.current !== input) { return @@ -53,15 +55,15 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient req .then(raw => { - const r = asRpcResult(raw) - if (ref.current !== input) { return } + const r = asRpcResult(raw) + setCompletions(r?.items ?? []) setCompIdx(0) - setCompReplace(isSlash ? (r?.replace_from ?? 1) : input.length - (pathWord?.length ?? 0)) + setCompReplace(isSlash ? (r?.replace_from ?? 1) : pathReplace) }) .catch((e: unknown) => { if (ref.current !== input) { @@ -76,7 +78,7 @@ export function useCompletion(input: string, blocked: boolean, gw: GatewayClient } ]) setCompIdx(0) - setCompReplace(isSlash ? 1 : input.length - (pathWord?.length ?? 0)) + setCompReplace(isSlash ? 1 : pathReplace) }) }, 60) diff --git a/ui-tui/src/hooks/useInputHistory.ts b/ui-tui/src/hooks/useInputHistory.ts index 369a9f50f1..8192b86c8f 100644 --- a/ui-tui/src/hooks/useInputHistory.ts +++ b/ui-tui/src/hooks/useInputHistory.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useRef, useState } from 'react' import * as inputHistory from '../lib/history.js' @@ -7,9 +7,5 @@ export function useInputHistory() { const [historyIdx, setHistoryIdx] = useState(null) const historyDraftRef = useRef('') - const pushHistory = useCallback((text: string) => { - inputHistory.append(text) - }, []) - - return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory } + return { historyRef, historyIdx, setHistoryIdx, historyDraftRef, pushHistory: inputHistory.append } } diff --git a/ui-tui/src/hooks/useQueue.ts b/ui-tui/src/hooks/useQueue.ts index 21bdd51c9e..7546d64e74 100644 --- a/ui-tui/src/hooks/useQueue.ts +++ b/ui-tui/src/hooks/useQueue.ts @@ -6,9 +6,7 @@ export function useQueue() { const queueEditRef = useRef(null) const [queueEditIdx, setQueueEditIdx] = useState(null) - const syncQueue = useCallback(() => { - setQueuedDisplay([...queueRef.current]) - }, []) + const syncQueue = useCallback(() => setQueuedDisplay([...queueRef.current]), []) const setQueueEdit = useCallback((idx: number | null) => { queueEditRef.current = idx @@ -39,12 +37,12 @@ export function useQueue() { ) return { - queueRef, - queueEditRef, - queuedDisplay, - queueEditIdx, - enqueue, dequeue, + enqueue, + queueEditIdx, + queueEditRef, + queueRef, + queuedDisplay, replaceQ, setQueueEdit, syncQueue diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index de6e71e2ea..b92c07c462 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -33,9 +33,9 @@ export function useVirtualHistory( items: readonly { key: string }[], { estimate = ESTIMATE, overscan = OVERSCAN, maxMounted = MAX_MOUNTED, coldStartCount = COLD_START } = {} ) { - const nodes = useRef(new Map()) + const nodes = useRef(new Map()) const heights = useRef(new Map()) - const refs = useRef(new Map void>()) + const refs = useRef(new Map void>()) const [ver, setVer] = useState(0) useSyncExternalStore( @@ -108,7 +108,7 @@ export function useVirtualHistory( let fn = refs.current.get(key) if (!fn) { - fn = (el: any) => (el ? nodes.current.set(key, el) : nodes.current.delete(key)) + fn = (el: unknown) => (el ? nodes.current.set(key, el) : nodes.current.delete(key)) refs.current.set(key, fn) } @@ -125,7 +125,7 @@ export function useVirtualHistory( continue } - const h = Math.ceil(nodes.current.get(k)?.yogaNode?.getComputedHeight?.() ?? 0) + const h = Math.ceil((nodes.current.get(k) as MeasuredNode | undefined)?.yogaNode?.getComputedHeight?.() ?? 0) if (h > 0 && heights.current.get(k) !== h) { heights.current.set(k, h) @@ -139,11 +139,15 @@ export function useVirtualHistory( }, [end, items, start]) return { - start, - end, - offsets, - topSpacer: offsets[start] ?? 0, bottomSpacer: Math.max(0, total - (offsets[end] ?? total)), - measureRef + end, + measureRef, + offsets, + start, + topSpacer: offsets[start] ?? 0 } } + +interface MeasuredNode { + yogaNode?: { getComputedHeight?: () => number } | null +} diff --git a/ui-tui/src/lib/history.ts b/ui-tui/src/lib/history.ts index 87adb2eb52..9affbb8085 100644 --- a/ui-tui/src/lib/history.ts +++ b/ui-tui/src/lib/history.ts @@ -3,12 +3,12 @@ import { homedir } from 'node:os' import { join } from 'node:path' const MAX = 1000 -const dir = join(process.env.HERMES_HOME ?? join(homedir(), '.hermes')) +const dir = process.env.HERMES_HOME ?? join(homedir(), '.hermes') const file = join(dir, '.hermes_history') let cache: string[] | null = null -export function load(): string[] { +export function load() { if (cache) { return cache } @@ -20,11 +20,10 @@ export function load(): string[] { return cache } - const lines = readFileSync(file, 'utf8').split('\n') const entries: string[] = [] let current: string[] = [] - for (const line of lines) { + for (const line of readFileSync(file, 'utf8').split('\n')) { if (line.startsWith('+')) { current.push(line.slice(1)) } else if (current.length) { @@ -45,7 +44,7 @@ export function load(): string[] { return cache } -export function append(line: string): void { +export function append(line: string) { const trimmed = line.trim() if (!trimmed) { @@ -73,11 +72,11 @@ export function append(line: string): void { const encoded = trimmed .split('\n') - .map(l => '+' + l) + .map(l => `+${l}`) .join('\n') appendFileSync(file, `\n# ${ts}\n${encoded}\n`) } catch { - /* ignore */ + void 0 } } diff --git a/ui-tui/src/lib/messages.ts b/ui-tui/src/lib/messages.ts index fc265abd4b..a459ec5a8a 100644 --- a/ui-tui/src/lib/messages.ts +++ b/ui-tui/src/lib/messages.ts @@ -1,5 +1,4 @@ import type { Msg, Role } from '../types.js' -export function upsert(prev: Msg[], role: Role, text: string): Msg[] { - return prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] -} +export const upsert = (prev: Msg[], role: Role, text: string): Msg[] => + prev.at(-1)?.role === role ? [...prev.slice(0, -1), { role, text }] : [...prev, { role, text }] diff --git a/ui-tui/src/lib/osc52.ts b/ui-tui/src/lib/osc52.ts index 01688aca6a..d990829921 100644 --- a/ui-tui/src/lib/osc52.ts +++ b/ui-tui/src/lib/osc52.ts @@ -1,3 +1,2 @@ -export function writeOsc52Clipboard(s: string): void { - process.stdout.write('\x1b]52;c;' + Buffer.from(s, 'utf8').toString('base64') + '\x07') -} +export const writeOsc52Clipboard = (s: string) => + process.stdout.write(`\x1b]52;c;${Buffer.from(s, 'utf8').toString('base64')}\x07`) diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index c2360dd0ca..1697d142bb 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -2,13 +2,8 @@ import type { CommandDispatchResponse } from '../gatewayTypes.js' export type RpcResult = Record -export const asRpcResult = (value: unknown): T | null => { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return null - } - - return value as T -} +export const asRpcResult = (value: unknown): T | null => + !value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T) export const asCommandDispatch = (value: unknown): CommandDispatchResponse | null => { const o = asRpcResult(value) @@ -28,24 +23,11 @@ export const asCommandDispatch = (value: unknown): CommandDispatchResponse | nul } if (t === 'skill' && typeof o.name === 'string') { - return { - type: 'skill', - name: o.name, - message: typeof o.message === 'string' ? o.message : undefined - } + return { type: 'skill', name: o.name, message: typeof o.message === 'string' ? o.message : undefined } } return null } -export const rpcErrorMessage = (err: unknown) => { - if (err instanceof Error && err.message) { - return err.message - } - - if (typeof err === 'string' && err.trim()) { - return err - } - - return 'request failed' -} +export const rpcErrorMessage = (err: unknown) => + err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed' diff --git a/ui-tui/src/lib/text.ts b/ui-tui/src/lib/text.ts index c6b991a5ee..fb10d7d2d4 100644 --- a/ui-tui/src/lib/text.ts +++ b/ui-tui/src/lib/text.ts @@ -1,12 +1,13 @@ import { THINKING_COT_MAX } from '../config/limits.js' import type { ThinkingMode } from '../types.js' -// eslint-disable-next-line no-control-regex -const ANSI_RE = /\x1b\[[0-9;]*m/g +const ESC = String.fromCharCode(27) +const ANSI_RE = new RegExp(`${ESC}\\[[0-9;]*m`, 'g') +const WS_RE = /\s+/g export const stripAnsi = (s: string) => s.replace(ANSI_RE, '') -export const hasAnsi = (s: string) => s.includes('\x1b[') || s.includes('\x1b]') +export const hasAnsi = (s: string) => s.includes(`${ESC}[`) || s.includes(`${ESC}]`) const renderEstimateLine = (line: string) => { const trimmed = line.trim() @@ -38,7 +39,7 @@ const renderEstimateLine = (line: string) => { } export const compactPreview = (s: string, max: number) => { - const one = s.replace(/\s+/g, ' ').trim() + const one = s.replace(WS_RE, ' ').trim() return !one ? '' : one.length > max ? one.slice(0, max - 1) + '…' : one } @@ -46,17 +47,13 @@ export const compactPreview = (s: string, max: number) => { export const estimateTokensRough = (text: string) => (!text ? 0 : (text.length + 3) >> 2) export const edgePreview = (s: string, head = 16, tail = 28) => { - const one = s.replace(/\s+/g, ' ').trim().replace(/\]\]/g, '] ]') + const one = s.replace(WS_RE, ' ').trim().replace(/\]\]/g, '] ]') - if (!one) { - return '' - } - - if (one.length <= head + tail + 4) { - return one - } - - return `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` + return !one + ? '' + : one.length <= head + tail + 4 + ? one + : `${one.slice(0, head).trimEnd()}.. ${one.slice(-tail).trimStart()}` } export const pasteTokenLabel = (text: string, lineCount: number) => { @@ -76,15 +73,7 @@ export const pasteTokenLabel = (text: string, lineCount: number) => { export const thinkingPreview = (reasoning: string, mode: ThinkingMode, max: number = THINKING_COT_MAX) => { const raw = reasoning.trim() - if (!raw || mode === 'collapsed') { - return '' - } - - if (mode === 'full') { - return raw - } - - return compactPreview(raw.replace(/\s+/g, ' '), max) + return !raw || mode === 'collapsed' ? '' : mode === 'full' ? raw : compactPreview(raw.replace(WS_RE, ' '), max) } export const stripTrailingPasteNewlines = (text: string) => (/[^\n]/.test(text) ? text.replace(/\n+$/, '') : text) @@ -97,18 +86,18 @@ export const toolTrailLabel = (name: string) => .join(' ') || name export const formatToolCall = (name: string, context = '') => { + const label = toolTrailLabel(name) const preview = compactPreview(context, 64) - return preview ? `${toolTrailLabel(name)}("${preview}")` : toolTrailLabel(name) + return preview ? `${label}("${preview}")` : label } -export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string): string => { +export const buildToolTrailLine = (name: string, context: string, error?: boolean, note?: string) => { const detail = compactPreview(note ?? '', 72) return `${formatToolCall(name, context)}${detail ? ` :: ${detail}` : ''} ${error ? ' ✗' : ' ✓'}` } -/** Tool completed / failed row in the inline trail (not CoT prose). */ export const isToolTrailResultLine = (line: string) => line.endsWith(' ✓') || line.endsWith(' ✗') export const parseToolTrailResultLine = (line: string) => { @@ -133,10 +122,8 @@ export const parseToolTrailResultLine = (line: string) => { return { call: body, detail: '', mark } } -/** Ephemeral status lines that should vanish once the next phase starts. */ export const isTransientTrailLine = (line: string) => line.startsWith('drafting ') || line === 'analyzing tool output…' -/** Whether a persisted/activity tool line belongs to the same tool label as a newer line. */ export const sameToolTrailGroup = (label: string, entry: string) => entry === `${label} ✓` || entry === `${label} ✗` || @@ -144,7 +131,6 @@ export const sameToolTrailGroup = (label: string, entry: string) => entry.startsWith(`${label} ::`) || entry.startsWith(`${label}:`) -/** Index of the last non-result trail line, or -1. */ export const lastCotTrailIndex = (trail: readonly string[]) => { for (let i = trail.length - 1; i >= 0; i--) { if (!isToolTrailResultLine(trail[i]!)) { @@ -168,10 +154,7 @@ export const estimateRows = (text: string, w: number, compact = false) => { const lang = maybeFence[2]!.trim() if (!fence) { - fence = { - char: marker[0] as '`' | '~', - len: marker.length - } + fence = { char: marker[0] as '`' | '~', len: marker.length } if (lang) { rows += Math.ceil((`─ ${lang}`.length || 1) / w) @@ -204,14 +187,11 @@ export const estimateRows = (text: string, w: number, compact = false) => { export const flat = (r: Record) => Object.values(r).flat() -const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { - maximumFractionDigits: 1, - notation: 'compact' -}) +const COMPACT_NUMBER = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1, notation: 'compact' }) export const fmtK = (n: number) => COMPACT_NUMBER.format(n).replace(/[KMBT]$/, s => s.toLowerCase()) export const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]! -export const isPasteBackedText = (text: string): boolean => +export const isPasteBackedText = (text: string) => /\[\[paste:\d+(?:[^\n]*?)\]\]|\[paste #\d+ (?:attached|excerpt)(?:[^\n]*?)\]/.test(text) diff --git a/ui-tui/src/protocol/interpolation.ts b/ui-tui/src/protocol/interpolation.ts index b83d16c5c2..804cf1cf04 100644 --- a/ui-tui/src/protocol/interpolation.ts +++ b/ui-tui/src/protocol/interpolation.ts @@ -1,7 +1,3 @@ export const INTERPOLATION_RE = /\{!(.+?)\}/g -export const hasInterpolation = (s: string) => { - INTERPOLATION_RE.lastIndex = 0 - - return INTERPOLATION_RE.test(s) -} +export const hasInterpolation = (s: string) => /\{!.+?\}/.test(s) diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 317d33c971..ab7d7efab9 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -1,7 +1,7 @@ export interface ActiveTool { + context?: string id: string name: string - context?: string startedAt?: number } @@ -36,11 +36,11 @@ export interface ClarifyReq { } export interface Msg { + info?: SessionInfo + kind?: 'intro' | 'panel' | 'slash' | 'trail' + panelData?: PanelData role: Role text: string - kind?: 'intro' | 'panel' | 'slash' | 'trail' - info?: SessionInfo - panelData?: PanelData thinking?: string thinkingTokens?: number toolTokens?: number @@ -76,6 +76,7 @@ export interface Usage { export interface SudoReq { requestId: string } + export interface SecretReq { envVar: string prompt: string From 5435287dec84a4745f24fa51fd50f45665ca15c9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 16 Apr 2026 22:35:45 -0500 Subject: [PATCH 146/157] chore: uptick --- ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx index c4cafcc4d7..aac8f2b334 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/ScrollBox.tsx @@ -235,7 +235,7 @@ function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren< // notify/scrollMutated are inline (no useCallback) but only close over // refs + imports — stable. Empty deps avoids rebuilding the handle on // every render (which re-registers the ref = churn). - + [] ) From 0219da9626d8a3339ce6f442c5d57f85efb4a654 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 09:47:19 -0500 Subject: [PATCH 147/157] chore: uptick --- hermes_cli/main.py | 2307 +++++++++++++++++++++++++++++++------------- 1 file changed, 1646 insertions(+), 661 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0e411a9d0b..ffcb9f53fd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -51,6 +51,7 @@ import sys from pathlib import Path from typing import Optional + def _require_tty(command_name: str) -> None: """Exit with a clear error if stdin is not a terminal. @@ -72,6 +73,7 @@ def _require_tty(command_name: str) -> None: PROJECT_ROOT = Path(__file__).parent.parent.resolve() sys.path.insert(0, str(PROJECT_ROOT)) + # --------------------------------------------------------------------------- # Profile override — MUST happen before any hermes module import. # @@ -102,6 +104,7 @@ def _apply_profile_override() -> None: if profile_name is None: try: from hermes_constants import get_default_hermes_root + active_path = get_default_hermes_root() / "active_profile" if active_path.exists(): name = active_path.read_text().strip() @@ -115,13 +118,17 @@ def _apply_profile_override() -> None: if profile_name is not None: try: from hermes_cli.profiles import resolve_profile_env + hermes_home = resolve_profile_env(profile_name) except (ValueError, FileNotFoundError) as exc: print(f"Error: {exc}", file=sys.stderr) sys.exit(1) except Exception as exc: # A bug in profiles.py must NEVER prevent hermes from starting - print(f"Warning: profile override failed ({exc}), using default", file=sys.stderr) + print( + f"Warning: profile override failed ({exc}), using default", + file=sys.stderr, + ) return os.environ["HERMES_HOME"] = hermes_home # Strip the flag from argv so argparse doesn't choke @@ -129,25 +136,28 @@ def _apply_profile_override() -> None: for i, arg in enumerate(argv): if arg in ("--profile", "-p"): start = i + 1 # +1 because argv is sys.argv[1:] - sys.argv = sys.argv[:start] + sys.argv[start + consume:] + sys.argv = sys.argv[:start] + sys.argv[start + consume :] break elif arg.startswith("--profile="): start = i + 1 - sys.argv = sys.argv[:start] + sys.argv[start + 1:] + sys.argv = sys.argv[:start] + sys.argv[start + 1 :] break + _apply_profile_override() # Load .env from ~/.hermes/.env first, then project root as dev fallback. # User-managed env files should override stale shell exports on restart. from hermes_cli.config import get_hermes_home from hermes_cli.env_loader import load_hermes_dotenv -load_hermes_dotenv(project_env=PROJECT_ROOT / '.env') + +load_hermes_dotenv(project_env=PROJECT_ROOT / ".env") # Initialize centralized file logging early — all `hermes` subcommands # (chat, setup, gateway, config, etc.) write to agent.log + errors.log. try: from hermes_logging import setup_logging as _setup_logging + _setup_logging(mode="cli") except Exception: pass # best-effort — don't crash the CLI if logging setup fails @@ -156,6 +166,7 @@ except Exception: try: from hermes_cli.config import load_config as _load_config_early from hermes_constants import apply_ipv4_preference as _apply_ipv4 + _early_cfg = _load_config_early() _net = _early_cfg.get("network", {}) if isinstance(_net, dict) and _net.get("force_ipv4"): @@ -202,6 +213,7 @@ def _has_any_provider_configured() -> bool: # tool credentials (Claude Code, Codex CLI) that shouldn't silently skip # the setup wizard on a fresh install. from hermes_cli.config import DEFAULT_CONFIG + _DEFAULT_MODEL = DEFAULT_CONFIG.get("model", "") cfg = load_config() model_cfg = cfg.get("model") @@ -219,7 +231,13 @@ def _has_any_provider_configured() -> bool: from hermes_cli.auth import PROVIDER_REGISTRY # Collect all provider env vars - provider_env_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "OPENAI_BASE_URL"} + provider_env_vars = { + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "ANTHROPIC_TOKEN", + "OPENAI_BASE_URL", + } for pconfig in PROVIDER_REGISTRY.values(): if pconfig.auth_type == "api_key": provider_env_vars.update(pconfig.api_key_env_vars) @@ -257,6 +275,7 @@ def _has_any_provider_configured() -> bool: if auth_file.exists(): try: import json + auth = json.loads(auth_file.read_text()) active = auth.get("active_provider") if active: @@ -266,7 +285,6 @@ def _has_any_provider_configured() -> bool: except Exception: pass - # Check config.yaml — if model is a dict with an explicit provider set, # the user has gone through setup (fresh installs have model as a plain # string). Also covers custom endpoints that store api_key/base_url in @@ -283,9 +301,15 @@ def _has_any_provider_configured() -> bool: # being installed doesn't mean the user wants Hermes to use their tokens. if _has_hermes_config: try: - from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid + from agent.anthropic_adapter import ( + read_claude_code_credentials, + is_claude_code_token_valid, + ) + creds = read_claude_code_credentials() - if creds and (is_claude_code_token_valid(creds) or creds.get("refreshToken")): + if creds and ( + is_claude_code_token_valid(creds) or creds.get("refreshToken") + ): return True except Exception: pass @@ -347,10 +371,10 @@ def _session_browse_picker(sessions: list) -> Optional[str]: if curses.has_colors(): curses.start_color() curses.use_default_colors() - curses.init_pair(1, curses.COLOR_GREEN, -1) # selected + curses.init_pair(1, curses.COLOR_GREEN, -1) # selected curses.init_pair(2, curses.COLOR_YELLOW, -1) # header - curses.init_pair(3, curses.COLOR_CYAN, -1) # search - curses.init_pair(4, 8, -1) # dim + curses.init_pair(3, curses.COLOR_CYAN, -1) # search + curses.init_pair(4, 8, -1) # dim cursor = 0 scroll_offset = 0 @@ -391,7 +415,9 @@ def _session_browse_picker(sessions: list) -> Optional[str]: name_width = max(20, max_x - fixed_cols) col_header = f" {'Title / Preview':<{name_width}} {'Active':<10} {'Src':<5} {'ID'}" try: - dim_attr = curses.color_pair(4) if curses.has_colors() else curses.A_DIM + dim_attr = ( + curses.color_pair(4) if curses.has_colors() else curses.A_DIM + ) stdscr.addnstr(1, 0, col_header, max_x - 1, dim_attr) except curses.error: pass @@ -418,10 +444,12 @@ def _session_browse_picker(sessions: list) -> Optional[str]: elif cursor >= scroll_offset + visible_rows: scroll_offset = cursor - visible_rows + 1 - for draw_i, i in enumerate(range( - scroll_offset, - min(len(filtered), scroll_offset + visible_rows) - )): + for draw_i, i in enumerate( + range( + scroll_offset, + min(len(filtered), scroll_offset + visible_rows), + ) + ): y = draw_i + 3 if y >= max_y - 1: break @@ -447,18 +475,23 @@ def _session_browse_picker(sessions: list) -> Optional[str]: else: footer = f" 0/{len(sessions)} sessions" try: - stdscr.addnstr(footer_y, 0, footer, max_x - 1, - curses.color_pair(4) if curses.has_colors() else curses.A_DIM) + stdscr.addnstr( + footer_y, + 0, + footer, + max_x - 1, + curses.color_pair(4) if curses.has_colors() else curses.A_DIM, + ) except curses.error: pass stdscr.refresh() key = stdscr.getch() - if key in (curses.KEY_UP, ): + if key in (curses.KEY_UP,): if filtered: cursor = (cursor - 1) % len(filtered) - elif key in (curses.KEY_DOWN, ): + elif key in (curses.KEY_DOWN,): if filtered: cursor = (cursor + 1) % len(filtered) elif key in (curses.KEY_ENTER, 10, 13): @@ -484,7 +517,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]: filtered = list(sessions) cursor = 0 scroll_offset = 0 - elif key == ord('q') and not search_text: + elif key == ord("q") and not search_text: return elif 32 <= key <= 126: # Printable character → add to search filter @@ -531,6 +564,7 @@ def _resolve_last_session(source: str = "cli") -> Optional[str]: """Look up the most recent session ID for a source.""" try: from hermes_state import SessionDB + db = SessionDB() sessions = db.search_sessions(source=source, limit=1) db.close() @@ -580,8 +614,10 @@ def _exec_in_container(container_info: dict, cli_args: list): runtime = shutil.which(backend) if not runtime: - print(f"Error: {backend} not found on PATH. Cannot route to container.", - file=sys.stderr) + print( + f"Error: {backend} not found on PATH. Cannot route to container.", + file=sys.stderr, + ) sys.exit(1) # Rootful containers (NixOS systemd service) are invisible to unprivileged @@ -589,14 +625,16 @@ def _exec_in_container(container_info: dict, cli_args: list): # Probe whether the runtime can see the container; if not, try via sudo. sudo_path = None probe = _probe_container( - [runtime, "inspect", "--format", "ok", container_name], backend, + [runtime, "inspect", "--format", "ok", container_name], + backend, ) if probe.returncode != 0: sudo_path = shutil.which("sudo") if sudo_path: probe2 = _probe_container( [sudo_path, "-n", runtime, "inspect", "--format", "ok", container_name], - backend, via_sudo=True, + backend, + via_sudo=True, ) if probe2.returncode != 0: print( @@ -609,10 +647,10 @@ def _exec_in_container(container_info: dict, cli_args: list): f"\n" f"On NixOS:\n" f"\n" - f' security.sudo.extraRules = [{{\n' + f" security.sudo.extraRules = [{{\n" f' users = [ "{os.getenv("USER", "your-user")}" ];\n' f' commands = [{{ command = "{runtime}"; options = [ "NOPASSWD" ]; }}];\n' - f' }}];\n' + f" }}];\n" f"\n" f"Or run: sudo hermes {' '.join(cli_args)}", file=sys.stderr, @@ -637,7 +675,8 @@ def _exec_in_container(container_info: dict, cli_args: list): cmd_prefix = [sudo_path, "-n", runtime] if sudo_path else [runtime] exec_cmd = ( - cmd_prefix + ["exec"] + cmd_prefix + + ["exec"] + tty_flags + ["-u", exec_user] + env_flags @@ -657,6 +696,7 @@ def _resolve_session_by_name_or_id(name_or_id: str) -> Optional[str]: """ try: from hermes_state import SessionDB + db = SessionDB() # Try as exact session ID first @@ -683,6 +723,7 @@ def _print_tui_exit_summary(session_id: Optional[str]) -> None: db = None try: from hermes_state import SessionDB + db = SessionDB() session = db.get_session(target) if not session: @@ -712,7 +753,7 @@ def _print_tui_exit_summary(session_id: Optional[str]) -> None: print("Resume this session with:") print(f" hermes --tui --resume {target}") if title: - print(f" hermes --tui -c \"{title}\"") + print(f' hermes --tui -c "{title}"') print() print(f"Session: {target}") if title: @@ -763,7 +804,12 @@ def _tui_build_needed(tui_dir: Path) -> bool: if fn.endswith((".ts", ".tsx")): if os.path.getmtime(os.path.join(dirpath, fn)) > dist_m: return True - for meta in ("package.json", "package-lock.json", "tsconfig.json", "tsconfig.build.json"): + for meta in ( + "package.json", + "package-lock.json", + "tsconfig.json", + "tsconfig.build.json", + ): mp = tui_dir / meta if mp.exists() and mp.stat().st_mtime > dist_m: return True @@ -817,7 +863,11 @@ def _ensure_tui_node() -> None: # on stdout once ensure_node succeeds. Subshell PATH edits don't leak # back into Python, so the stdout capture is the bridge. result = subprocess.run( - ["bash", "-c", f'source "{helper}" >&2 && ensure_node >&2 && command -v node'], + [ + "bash", + "-c", + f'source "{helper}" >&2 && ensure_node >&2 && command -v node', + ], env={**os.environ, "HERMES_HOME": hermes_home}, capture_output=True, text=True, @@ -846,7 +896,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: """TUI: --dev → tsx src; else node dist (HERMES_TUI_DIR or ui-tui, build when stale).""" _ensure_tui_node() - def _node_bin(bin: str)-> str: + def _node_bin(bin: str) -> str: path = shutil.which(bin) if not path: print(f"{bin} not found — install Node.js to use the TUI.") @@ -872,6 +922,7 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, + env={**os.environ, "CI": "1"}, ) if result.returncode != 0: err = (result.stderr or "").strip() @@ -924,12 +975,15 @@ def _make_tui_argv(tui_dir: Path, tui_dev: bool) -> tuple[list[str], Path]: node = _node_bin("node") return [node, str(root / "dist" / "entry.js")], root + def _launch_tui(resume_session_id: Optional[str] = None, tui_dev: bool = False): """Replace current process with the TUI.""" tui_dir = PROJECT_ROOT / "ui-tui" env = os.environ.copy() - env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get("HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT)) + env["HERMES_PYTHON_SRC_ROOT"] = os.environ.get( + "HERMES_PYTHON_SRC_ROOT", str(PROJECT_ROOT) + ) env.setdefault("HERMES_PYTHON", sys.executable) env.setdefault("HERMES_CWD", os.getcwd()) if resume_session_id: @@ -988,12 +1042,17 @@ def cmd_chat(args): # First-run guard: check if any provider is configured before launching if not _has_any_provider_configured(): print() - print("It looks like Hermes isn't configured yet -- no API keys or providers found.") + print( + "It looks like Hermes isn't configured yet -- no API keys or providers found." + ) print() print(" Run: hermes setup") print() - from hermes_cli.setup import is_interactive_stdin, print_noninteractive_setup_guidance + from hermes_cli.setup import ( + is_interactive_stdin, + print_noninteractive_setup_guidance, + ) if not is_interactive_stdin(): print_noninteractive_setup_guidance( @@ -1015,6 +1074,7 @@ def cmd_chat(args): # Start update check in background (runs while other init happens) try: from hermes_cli.banner import prefetch_update_check + prefetch_update_check() except Exception: pass @@ -1022,6 +1082,7 @@ def cmd_chat(args): # Sync bundled skills on every CLI launch (fast -- skips unchanged skills) try: from tools.skills_sync import sync_skills + sync_skills(quiet=True) except Exception: pass @@ -1034,7 +1095,6 @@ def cmd_chat(args): if getattr(args, "source", None): os.environ["HERMES_SESSION_SOURCE"] = args.source - if use_tui: _launch_tui( getattr(args, "resume", None), @@ -1043,7 +1103,7 @@ def cmd_chat(args): # Import and run the CLI from cli import main as cli_main - + # Build kwargs from args kwargs = { "model": args.model, @@ -1062,7 +1122,7 @@ def cmd_chat(args): } # Filter out None values kwargs = {k: v for k, v in kwargs.items() if v is not None} - + try: cli_main(**kwargs) except ValueError as e: @@ -1073,6 +1133,7 @@ def cmd_chat(args): def cmd_gateway(args): """Gateway management commands.""" from hermes_cli.gateway import gateway_command + gateway_command(args) @@ -1095,7 +1156,9 @@ def cmd_whatsapp(args): print() print(" 1. Separate bot number (recommended)") print(" People message the bot's number directly — cleanest experience.") - print(" Requires a second phone number with WhatsApp installed on a device.") + print( + " Requires a second phone number with WhatsApp installed on a device." + ) print() print(" 2. Personal number (self-chat)") print(" You message yourself to talk to the agent.") @@ -1130,7 +1193,9 @@ def cmd_whatsapp(args): print(" ✓ Mode: personal number (self-chat)") else: wa_mode = current_mode - mode_label = "separate bot number" if wa_mode == "bot" else "personal number (self-chat)" + mode_label = ( + "separate bot number" if wa_mode == "bot" else "personal number (self-chat)" + ) print(f"\n✓ Mode: {mode_label}") # ── Step 2: Enable WhatsApp ────────────────────────────────────────── @@ -1152,7 +1217,9 @@ def cmd_whatsapp(args): response = "n" if response.lower() in ("y", "yes"): if wa_mode == "bot": - phone = input(" Phone numbers that can message the bot (comma-separated): ").strip() + phone = input( + " Phone numbers that can message the bot (comma-separated): " + ).strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: @@ -1162,7 +1229,9 @@ def cmd_whatsapp(args): print() if wa_mode == "bot": print(" Who should be allowed to message the bot?") - phone = input(" Phone numbers (comma-separated, or * for anyone): ").strip() + phone = input( + " Phone numbers (comma-separated, or * for anyone): " + ).strip() else: phone = input(" Your phone number (e.g. 15551234567): ").strip() if phone: @@ -1203,11 +1272,14 @@ def cmd_whatsapp(args): if (session_dir / "creds.json").exists(): print("✓ Existing WhatsApp session found") try: - response = input("\n Re-pair? This will clear the existing session. [y/N] ").strip() + response = input( + "\n Re-pair? This will clear the existing session. [y/N] " + ).strip() except (EOFError, KeyboardInterrupt): response = "n" if response.lower() in ("y", "yes"): import shutil + shutil.rmtree(session_dir, ignore_errors=True) session_dir.mkdir(parents=True, exist_ok=True) print(" ✓ Session cleared") @@ -1266,6 +1338,7 @@ def cmd_whatsapp(args): def cmd_setup(args): """Interactive setup wizard.""" from hermes_cli.setup import run_setup_wizard + run_setup_wizard(args) @@ -1284,9 +1357,15 @@ def select_provider_and_model(args=None): persistence. """ from hermes_cli.auth import ( - resolve_provider, AuthError, format_auth_error, + resolve_provider, + AuthError, + format_auth_error, + ) + from hermes_cli.config import ( + get_compatible_custom_providers, + load_config, + get_env_value, ) - from hermes_cli.config import get_compatible_custom_providers, load_config, get_env_value config = load_config() current_model = config.get("model") @@ -1297,15 +1376,14 @@ def select_provider_and_model(args=None): # Read effective provider the same way the CLI does at startup: # config.yaml model.provider > env var > auto-detect import os + config_provider = None model_cfg = config.get("model") if isinstance(model_cfg, dict): config_provider = model_cfg.get("provider") effective_provider = ( - config_provider - or os.getenv("HERMES_INFERENCE_PROVIDER") - or "auto" + config_provider or os.getenv("HERMES_INFERENCE_PROVIDER") or "auto" ) try: active = resolve_provider(effective_provider) @@ -1362,7 +1440,9 @@ def select_provider_and_model(args=None): return custom_provider_map # Add user-defined custom providers from config.yaml - _custom_provider_map = _named_custom_provider_map(config) # key → {name, base_url, api_key} + _custom_provider_map = _named_custom_provider_map( + config + ) # key → {name, base_url, api_key} for key, provider_info in _custom_provider_map.items(): name = provider_info["name"] base_url = provider_info["base_url"] @@ -1382,13 +1462,16 @@ def select_provider_and_model(args=None): ordered.append((key, label)) ordered.append(("custom", "Custom endpoint (enter URL manually)")) - _has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool(config.get("custom_providers")) + _has_saved_custom_list = isinstance(config.get("custom_providers"), list) and bool( + config.get("custom_providers") + ) if _has_saved_custom_list: ordered.append(("remove-custom", "Remove a saved custom provider")) ordered.append(("cancel", "Cancel")) provider_idx = _prompt_provider_choice( - [label for _, label in ordered], default=default_idx, + [label for _, label in ordered], + default=default_idx, ) if provider_idx is None or ordered[provider_idx][0] == "cancel": print("No change.") @@ -1413,7 +1496,10 @@ def select_provider_and_model(args=None): _model_flow_copilot(config, current_model) elif selected_provider == "custom": _model_flow_custom(config) - elif selected_provider.startswith("custom:") or selected_provider in _custom_provider_map: + elif ( + selected_provider.startswith("custom:") + or selected_provider in _custom_provider_map + ): provider_info = _named_custom_provider_map(load_config()).get(selected_provider) if provider_info is None: print( @@ -1430,15 +1516,35 @@ def select_provider_and_model(args=None): _model_flow_kimi(config, current_model) elif selected_provider == "bedrock": _model_flow_bedrock(config, current_model) - elif selected_provider in ("gemini", "deepseek", "xai", "zai", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "opencode-zen", "opencode-go", "ai-gateway", "alibaba", "huggingface", "xiaomi", "arcee", "ollama-cloud"): + elif selected_provider in ( + "gemini", + "deepseek", + "xai", + "zai", + "kimi-coding-cn", + "minimax", + "minimax-cn", + "kilocode", + "opencode-zen", + "opencode-go", + "ai-gateway", + "alibaba", + "huggingface", + "xiaomi", + "arcee", + "ollama-cloud", + ): _model_flow_api_key_provider(config, selected_provider, current_model) # ── Post-switch cleanup: clear stale OPENAI_BASE_URL ────────────── # When the user switches to a named provider (anything except "custom"), # a leftover OPENAI_BASE_URL in ~/.hermes/.env can poison auxiliary # clients that use provider:auto. Clear it proactively. (#5161) - if selected_provider not in ("custom", "cancel", "remove-custom") \ - and not selected_provider.startswith("custom:"): + if selected_provider not in ( + "custom", + "cancel", + "remove-custom", + ) and not selected_provider.startswith("custom:"): _clear_stale_openai_base_url() @@ -1465,9 +1571,11 @@ def _clear_stale_openai_base_url(): stale_url = get_env_value("OPENAI_BASE_URL") if stale_url: save_env_value("OPENAI_BASE_URL", "") - print(f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)" - if len(stale_url) > 40 - else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})") + print( + f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url[:40]}...)" + if len(stale_url) > 40 + else f"Cleared stale OPENAI_BASE_URL from .env (was: {stale_url})" + ) def _prompt_provider_choice(choices, *, default=0): @@ -1479,6 +1587,7 @@ def _prompt_provider_choice(choices, *, default=0): """ try: from hermes_cli.setup import _curses_prompt_choice + idx = _curses_prompt_choice("Select provider:", choices, default) if idx >= 0: print() @@ -1510,7 +1619,11 @@ def _prompt_provider_choice(choices, *, default=0): def _model_flow_openrouter(config, current_model=""): """OpenRouter provider: ensure API key, then pick model.""" - from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider + from hermes_cli.auth import ( + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + ) from hermes_cli.config import get_env_value, save_env_value api_key = get_env_value("OPENROUTER_API_KEY") @@ -1520,6 +1633,7 @@ def _model_flow_openrouter(config, current_model=""): print() try: import getpass + key = getpass.getpass("OpenRouter API key (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -1532,17 +1646,21 @@ def _model_flow_openrouter(config, current_model=""): print() from hermes_cli.models import model_ids, get_pricing_for_provider + openrouter_models = model_ids(force_refresh=True) # Fetch live pricing (non-blocking — returns empty dict on failure) pricing = get_pricing_for_provider("openrouter", force_refresh=True) - selected = _prompt_model_selection(openrouter_models, current_model=current_model, pricing=pricing) + selected = _prompt_model_selection( + openrouter_models, current_model=current_model, pricing=pricing + ) if selected: _save_model_choice(selected) # Update config provider and deactivate any OAuth provider from hermes_cli.config import load_config, save_config + cfg = load_config() model = cfg.get("model") if not isinstance(model, dict): @@ -1561,12 +1679,22 @@ def _model_flow_openrouter(config, current_model=""): def _model_flow_nous(config, current_model="", args=None): """Nous Portal provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( - get_provider_auth_state, _prompt_model_selection, _save_model_choice, - _update_config_for_provider, resolve_nous_runtime_credentials, - AuthError, format_auth_error, - _login_nous, PROVIDER_REGISTRY, + get_provider_auth_state, + _prompt_model_selection, + _save_model_choice, + _update_config_for_provider, + resolve_nous_runtime_credentials, + AuthError, + format_auth_error, + _login_nous, + PROVIDER_REGISTRY, + ) + from hermes_cli.config import ( + get_env_value, + load_config, + save_config, + save_env_value, ) - from hermes_cli.config import get_env_value, load_config, save_config, save_env_value from hermes_cli.nous_subscription import prompt_enable_tool_gateway import argparse @@ -1605,9 +1733,13 @@ def _model_flow_nous(config, current_model="", args=None): # The live /models endpoint returns hundreds of models; the curated list # shows only agentic models users recognize from OpenRouter. from hermes_cli.models import ( - _PROVIDER_MODELS, get_pricing_for_provider, filter_nous_free_models, - check_nous_free_tier, partition_nous_models_by_tier, + _PROVIDER_MODELS, + get_pricing_for_provider, + filter_nous_free_models, + check_nous_free_tier, + partition_nous_models_by_tier, ) + model_ids = _PROVIDER_MODELS.get("nous", []) if not model_ids: print("No curated models available for Nous Portal.") @@ -1624,9 +1756,14 @@ def _model_flow_nous(config, current_model="", args=None): print("Re-authenticating with Nous Portal...\n") try: mock_args = argparse.Namespace( - portal_url=None, inference_url=None, client_id=None, - scope=None, no_browser=False, timeout=15.0, - ca_bundle=None, insecure=False, + portal_url=None, + inference_url=None, + client_id=None, + scope=None, + no_browser=False, + timeout=15.0, + ca_bundle=None, + insecure=False, ) _login_nous(mock_args, PROVIDER_REGISTRY["nous"]) except Exception as login_exc: @@ -1647,7 +1784,9 @@ def _model_flow_nous(config, current_model="", args=None): model_ids = filter_nous_free_models(model_ids, pricing) unavailable_models: list[str] = [] if free_tier: - model_ids, unavailable_models = partition_nous_models_by_tier(model_ids, pricing, free_tier=True) + model_ids, unavailable_models = partition_nous_models_by_tier( + model_ids, pricing, free_tier=True + ) if not model_ids and not unavailable_models: print("No models available for Nous Portal after filtering.") @@ -1666,15 +1805,21 @@ def _model_flow_nous(config, current_model="", args=None): print("No free models currently available.") if unavailable_models: from hermes_cli.auth import DEFAULT_NOUS_PORTAL_URL + _url = (_nous_portal_url or DEFAULT_NOUS_PORTAL_URL).rstrip("/") print(f"Upgrade at {_url} to access paid models.") return - print(f"Showing {len(model_ids)} curated models — use \"Enter custom model name\" for others.") + print( + f'Showing {len(model_ids)} curated models — use "Enter custom model name" for others.' + ) selected = _prompt_model_selection( - model_ids, current_model=current_model, pricing=pricing, - unavailable_models=unavailable_models, portal_url=_nous_portal_url, + model_ids, + current_model=current_model, + pricing=pricing, + unavailable_models=unavailable_models, + portal_url=_nous_portal_url, ) if selected: _save_model_choice(selected) @@ -1710,9 +1855,13 @@ def _model_flow_nous(config, current_model="", args=None): def _model_flow_openai_codex(config, current_model=""): """OpenAI Codex provider: ensure logged in, then pick model.""" from hermes_cli.auth import ( - get_codex_auth_status, _prompt_model_selection, _save_model_choice, - _update_config_for_provider, _login_openai_codex, - PROVIDER_REGISTRY, DEFAULT_CODEX_BASE_URL, + get_codex_auth_status, + _prompt_model_selection, + _save_model_choice, + _update_config_for_provider, + _login_openai_codex, + PROVIDER_REGISTRY, + DEFAULT_CODEX_BASE_URL, ) from hermes_cli.codex_models import get_codex_model_ids import argparse @@ -1743,6 +1892,7 @@ def _model_flow_openai_codex(config, current_model=""): if not _codex_token: try: from hermes_cli.auth import resolve_codex_runtime_credentials + _codex_creds = resolve_codex_runtime_credentials() _codex_token = _codex_creds.get("api_key") except Exception: @@ -1759,7 +1909,6 @@ def _model_flow_openai_codex(config, current_model=""): print("No change.") - _DEFAULT_QWEN_PORTAL_MODELS = [ "qwen3-coder-plus", "qwen3-coder", @@ -1862,7 +2011,9 @@ def _model_flow_google_gemini_cli(_config, current_model=""): if project_id: print(f" Using GCP project: {project_id}") else: - print(" No GCP project configured — free tier will be auto-provisioned on first request.") + print( + " No GCP project configured — free tier will be auto-provisioned on first request." + ) except Exception as exc: print(f"Failed to resolve Gemini credentials: {exc}") return @@ -1872,14 +2023,16 @@ def _model_flow_google_gemini_cli(_config, current_model=""): selected = _prompt_model_selection(models, current_model=default) if selected: _save_model_choice(selected) - _update_config_for_provider("google-gemini-cli", DEFAULT_GEMINI_CLOUDCODE_BASE_URL) - print(f"Default model set to: {selected} (via Google Gemini OAuth / Code Assist)") + _update_config_for_provider( + "google-gemini-cli", DEFAULT_GEMINI_CLOUDCODE_BASE_URL + ) + print( + f"Default model set to: {selected} (via Google Gemini OAuth / Code Assist)" + ) else: print("No change.") - - def _model_flow_custom(config): """Custom endpoint: collect URL, API key, and model name. @@ -1900,9 +2053,14 @@ def _model_flow_custom(config): print() try: - base_url = input(f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: ").strip() + base_url = input( + f"API base URL [{current_url or 'e.g. https://api.example.com/v1'}]: " + ).strip() import getpass - api_key = getpass.getpass(f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: ").strip() + + api_key = getpass.getpass( + f"API key [{current_key[:8] + '...' if current_key else 'optional'}]: " + ).strip() except (KeyboardInterrupt, EOFError): print("\nCancelled.") return @@ -1923,7 +2081,10 @@ def _model_flow_custom(config): # in the base URL for OpenAI-compatible chat completions. Prompt the # user if the URL looks like a local server without /v1. _url_lower = effective_url.rstrip("/").lower() - _looks_local = any(h in _url_lower for h in ("localhost", "127.0.0.1", "0.0.0.0", ":11434", ":8080", ":5000")) + _looks_local = any( + h in _url_lower + for h in ("localhost", "127.0.0.1", "0.0.0.0", ":11434", ":8080", ":5000") + ) if _looks_local and not _url_lower.endswith("/v1"): print() print(f" Hint: Did you mean to add /v1 at the end?") @@ -1964,7 +2125,9 @@ def _model_flow_custom(config): if probe.get("suggested_base_url"): suggested = probe["suggested_base_url"] if suggested.endswith("/v1"): - print(f" If this server expects /v1 in the path, try base URL: {suggested}") + print( + f" If this server expects /v1 in the path, try base URL: {suggested}" + ) else: print(f" If /v1 should not be in the base URL, try: {suggested}") @@ -1983,7 +2146,9 @@ def _model_flow_custom(config): print(" Available models:") for i, m in enumerate(detected_models, 1): print(f" {i}. {m}") - pick = input(f" Select model [1-{len(detected_models)}] or type name: ").strip() + pick = input( + f" Select model [1-{len(detected_models)}] or type name: " + ).strip() if pick.isdigit() and 1 <= int(pick) <= len(detected_models): model_name = detected_models[int(pick) - 1] elif pick: @@ -1991,7 +2156,9 @@ def _model_flow_custom(config): else: model_name = input("Model name (e.g. gpt-4, llama-3-70b): ").strip() - context_length_str = input("Context length in tokens [leave blank for auto-detect]: ").strip() + context_length_str = input( + "Context length in tokens [leave blank for auto-detect]: " + ).strip() # Prompt for a display name — shown in the provider menu on future runs default_name = _auto_provider_name(effective_url) @@ -2003,7 +2170,11 @@ def _model_flow_custom(config): context_length = None if context_length_str: try: - context_length = int(context_length_str.replace(",", "").replace("k", "000").replace("K", "000")) + context_length = int( + context_length_str.replace(",", "") + .replace("k", "000") + .replace("K", "000") + ) if context_length <= 0: context_length = None except ValueError: @@ -2051,8 +2222,13 @@ def _model_flow_custom(config): print("Endpoint saved. Use `/model` in chat or `hermes model` to set a model.") # Auto-save to custom_providers so it appears in the menu next time - _save_custom_provider(effective_url, effective_key, model_name or "", - context_length=context_length, name=display_name) + _save_custom_provider( + effective_url, + effective_key, + model_name or "", + context_length=context_length, + name=display_name, + ) def _auto_provider_name(base_url: str) -> str: @@ -2063,6 +2239,7 @@ def _auto_provider_name(base_url: str) -> str: user for a display name during custom endpoint setup. """ import re + clean = base_url.replace("https://", "").replace("http://", "").rstrip("/") clean = re.sub(r"/v1/?$", "", clean) name = clean.split("/")[0] @@ -2075,8 +2252,9 @@ def _auto_provider_name(base_url: str) -> str: return name -def _save_custom_provider(base_url, api_key="", model="", context_length=None, - name=None): +def _save_custom_provider( + base_url, api_key="", model="", context_length=None, name=None +): """Save a custom endpoint to custom_providers in config.yaml. Deduplicates by base_url — if the URL already exists, updates the @@ -2092,7 +2270,9 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None, # Check if this URL is already saved — update model/context_length if so for entry in providers: - if isinstance(entry, dict) and entry.get("base_url", "").rstrip("/") == base_url.rstrip("/"): + if isinstance(entry, dict) and entry.get("base_url", "").rstrip( + "/" + ) == base_url.rstrip("/"): changed = False if model and entry.get("model") != model: entry["model"] = model @@ -2124,7 +2304,7 @@ def _save_custom_provider(base_url, api_key="", model="", context_length=None, providers.append(entry) cfg["custom_providers"] = providers save_config(cfg) - print(f" 💾 Saved to custom providers as \"{name}\" (edit in config.yaml)") + print(f' 💾 Saved to custom providers as "{name}" (edit in config.yaml)') def _remove_custom_provider(config): @@ -2152,15 +2332,20 @@ def _remove_custom_provider(config): try: from simple_term_menu import TerminalMenu + menu = TerminalMenu( - [f" {c}" for c in choices], cursor_index=0, - menu_cursor="-> ", menu_cursor_style=("fg_red", "bold"), + [f" {c}" for c in choices], + cursor_index=0, + menu_cursor="-> ", + menu_cursor_style=("fg_red", "bold"), menu_highlight_style=("fg_red",), - cycle_cursor=True, clear_screen=False, + cycle_cursor=True, + clear_screen=False, title="Select provider to remove:", ) idx = menu.show() from hermes_cli.curses_ui import flush_stdin + flush_stdin() print() except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError): @@ -2180,8 +2365,10 @@ def _remove_custom_provider(config): removed = providers.pop(idx) cfg["custom_providers"] = providers save_config(cfg) - removed_name = removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed) - print(f"✅ Removed \"{removed_name}\" from custom providers.") + removed_name = ( + removed.get("name", "unnamed") if isinstance(removed, dict) else str(removed) + ) + print(f'✅ Removed "{removed_name}" from custom providers.') def _model_flow_named_custom(config, provider_info): @@ -2219,19 +2406,23 @@ def _model_flow_named_custom(config, provider_info): print(f"Found {len(models)} model(s):\n") try: from simple_term_menu import TerminalMenu + menu_items = [ - f" {m} (current)" if m == saved_model else f" {m}" - for m in models + f" {m} (current)" if m == saved_model else f" {m}" for m in models ] + [" Cancel"] menu = TerminalMenu( - menu_items, cursor_index=default_idx, - menu_cursor="-> ", menu_cursor_style=("fg_green", "bold"), + menu_items, + cursor_index=default_idx, + menu_cursor="-> ", + menu_cursor_style=("fg_green", "bold"), menu_highlight_style=("fg_green",), - cycle_cursor=True, clear_screen=False, + cycle_cursor=True, + clear_screen=False, title=f"Select model from {name}:", ) idx = menu.show() from hermes_cli.curses_ui import flush_stdin + flush_stdin() print() if idx is None or idx >= len(models): @@ -2344,7 +2535,11 @@ def _set_reasoning_effort(config, effort: str) -> None: def _prompt_reasoning_effort_selection(efforts, current_effort=""): """Prompt for a reasoning effort. Returns effort, 'none', or None to keep current.""" - deduped = list(dict.fromkeys(str(effort).strip().lower() for effort in efforts if str(effort).strip())) + deduped = list( + dict.fromkeys( + str(effort).strip().lower() for effort in efforts if str(effort).strip() + ) + ) canonical_order = ("minimal", "low", "medium", "high", "xhigh") ordered = [effort for effort in canonical_order if effort in deduped] ordered.extend(effort for effort in deduped if effort not in canonical_order) @@ -2386,6 +2581,7 @@ def _prompt_reasoning_effort_selection(efforts, current_effort=""): ) idx = menu.show() from hermes_cli.curses_ui import flush_stdin + flush_stdin() if idx is None: return None @@ -2454,7 +2650,9 @@ def _model_flow_copilot(config, current_model=""): print("No GitHub token configured for GitHub Copilot.") print() print(" Supported token types:") - print(" → OAuth token (gho_*) via `copilot login` or device code flow") + print( + " → OAuth token (gho_*) via `copilot login` or device code flow" + ) print(" → Fine-grained PAT (github_pat_*) with Copilot Requests permission") print(" → GitHub App token (ghu_*) via environment variable") print(" ✗ Classic PAT (ghp_*) NOT supported by Copilot API") @@ -2473,6 +2671,7 @@ def _model_flow_copilot(config, current_model=""): if choice == "1": try: from hermes_cli.copilot_auth import copilot_device_code_login + token = copilot_device_code_login() if token: save_env_value("COPILOT_GITHUB_TOKEN", token) @@ -2487,6 +2686,7 @@ def _model_flow_copilot(config, current_model=""): elif choice == "2": try: import getpass + new_key = getpass.getpass(" Token (COPILOT_GITHUB_TOKEN): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -2497,6 +2697,7 @@ def _model_flow_copilot(config, current_model=""): # Validate token type try: from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token(new_key) if not valid: print(f" ✗ {msg}") @@ -2525,23 +2726,34 @@ def _model_flow_copilot(config, current_model=""): effective_base = pconfig.inference_base_url catalog = fetch_github_model_catalog(api_key) - live_models = [item.get("id", "") for item in catalog if item.get("id")] if catalog else fetch_api_models(api_key, effective_base) - normalized_current_model = normalize_copilot_model_id( - current_model, - catalog=catalog, - api_key=api_key, - ) or current_model + live_models = ( + [item.get("id", "") for item in catalog if item.get("id")] + if catalog + else fetch_api_models(api_key, effective_base) + ) + normalized_current_model = ( + normalize_copilot_model_id( + current_model, + catalog=catalog, + api_key=api_key, + ) + or current_model + ) if live_models: model_list = [model_id for model_id in live_models if model_id] print(f" Found {len(model_list)} model(s) from GitHub Copilot") else: model_list = _PROVIDER_MODELS.get(provider_id, []) if model_list: - print(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.") + print( + " ⚠ Could not auto-detect models from GitHub Copilot — showing defaults." + ) print(' Use "Enter custom model name" if you do not see your model.') if model_list: - selected = _prompt_model_selection(model_list, current_model=normalized_current_model) + selected = _prompt_model_selection( + model_list, current_model=normalized_current_model + ) else: try: selected = input("Model name: ").strip() @@ -2549,11 +2761,14 @@ def _model_flow_copilot(config, current_model=""): selected = None if selected: - selected = normalize_copilot_model_id( - selected, - catalog=catalog, - api_key=api_key, - ) or selected + selected = ( + normalize_copilot_model_id( + selected, + catalog=catalog, + api_key=api_key, + ) + or selected + ) initial_cfg = load_config() current_effort = _current_reasoning_effort(initial_cfg) reasoning_efforts = github_model_reasoning_efforts( @@ -2620,7 +2835,9 @@ def _model_flow_copilot_acp(config, current_model=""): pconfig = PROVIDER_REGISTRY[provider_id] status = get_external_process_provider_status(provider_id) - resolved_command = status.get("resolved_command") or status.get("command") or "copilot" + resolved_command = ( + status.get("resolved_command") or status.get("command") or "copilot" + ) effective_base = status.get("base_url") or pconfig.inference_base_url print(" GitHub Copilot ACP delegates Hermes turns to `copilot --acp`.") @@ -2634,7 +2851,9 @@ def _model_flow_copilot_acp(config, current_model=""): creds = resolve_external_process_provider_credentials(provider_id) except Exception as exc: print(f" ⚠ {exc}") - print(" Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere.") + print( + " Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere." + ) return effective_base = creds.get("base_url") or effective_base @@ -2647,11 +2866,14 @@ def _model_flow_copilot_acp(config, current_model=""): pass catalog = fetch_github_model_catalog(catalog_api_key) - normalized_current_model = normalize_copilot_model_id( - current_model, - catalog=catalog, - api_key=catalog_api_key, - ) or current_model + normalized_current_model = ( + normalize_copilot_model_id( + current_model, + catalog=catalog, + api_key=catalog_api_key, + ) + or current_model + ) if catalog: model_list = [item.get("id", "") for item in catalog if item.get("id")] @@ -2659,7 +2881,9 @@ def _model_flow_copilot_acp(config, current_model=""): else: model_list = _PROVIDER_MODELS.get("copilot", []) if model_list: - print(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.") + print( + " ⚠ Could not auto-detect models from GitHub Copilot — showing defaults." + ) print(' Use "Enter custom model name" if you do not see your model.') if model_list: @@ -2677,11 +2901,14 @@ def _model_flow_copilot_acp(config, current_model=""): print("No change.") return - selected = normalize_copilot_model_id( - selected, - catalog=catalog, - api_key=catalog_api_key, - ) or selected + selected = ( + normalize_copilot_model_id( + selected, + catalog=catalog, + api_key=catalog_api_key, + ) + or selected + ) _save_model_choice(selected) cfg = load_config() @@ -2707,10 +2934,18 @@ def _model_flow_kimi(config, current_model=""): No manual base URL prompt — endpoint is determined by key prefix. """ from hermes_cli.auth import ( - PROVIDER_REGISTRY, KIMI_CODE_BASE_URL, _prompt_model_selection, - _save_model_choice, deactivate_provider, + PROVIDER_REGISTRY, + KIMI_CODE_BASE_URL, + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + ) + from hermes_cli.config import ( + get_env_value, + save_env_value, + load_config, + save_config, ) - from hermes_cli.config import get_env_value, save_env_value, load_config, save_config provider_id = "kimi-coding" pconfig = PROVIDER_REGISTRY[provider_id] @@ -2729,6 +2964,7 @@ def _model_flow_kimi(config, current_model=""): if key_env: try: import getpass + new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -2805,8 +3041,17 @@ def _model_flow_bedrock_api_key(config, region, current_model=""): For developers who don't have an AWS account but received a Bedrock API Key from their AWS admin. Works like any OpenAI-compatible endpoint. """ - from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider - from hermes_cli.config import load_config, save_config, get_env_value, save_env_value + from hermes_cli.auth import ( + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + ) + from hermes_cli.config import ( + load_config, + save_config, + get_env_value, + save_env_value, + ) from hermes_cli.models import _PROVIDER_MODELS mantle_base_url = f"https://bedrock-mantle.{region}.api.aws/v1" @@ -2820,6 +3065,7 @@ def _model_flow_bedrock_api_key(config, region, current_model=""): print() try: import getpass + api_key = getpass.getpass(" Bedrock API Key: ").strip() except (KeyboardInterrupt, EOFError): print() @@ -2884,7 +3130,11 @@ def _model_flow_bedrock(config, current_model=""): Auth is handled by the AWS SDK default credential chain (env vars, profile, instance role), so no API key prompt is needed. """ - from hermes_cli.auth import _prompt_model_selection, _save_model_choice, deactivate_provider + from hermes_cli.auth import ( + _prompt_model_selection, + _save_model_choice, + deactivate_provider, + ) from hermes_cli.config import load_config, save_config from hermes_cli.models import _PROVIDER_MODELS @@ -2948,8 +3198,13 @@ def _model_flow_bedrock(config, current_model=""): if live_models: _EXCLUDE_PREFIXES = ( - "stability.", "cohere.embed", "twelvelabs.", "us.stability.", - "us.cohere.embed", "us.twelvelabs.", "global.cohere.embed", + "stability.", + "cohere.embed", + "twelvelabs.", + "us.stability.", + "us.cohere.embed", + "us.twelvelabs.", + "global.cohere.embed", "global.twelvelabs.", ) _EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision") @@ -3001,13 +3256,19 @@ def _model_flow_bedrock(config, current_model=""): deduped.sort(key=_sort_key) model_list = [m["id"] for m in deduped] - print(f" Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)") + print( + f" Found {len(model_list)} text model(s) (filtered from {len(live_models)} total)" + ) else: model_list = _PROVIDER_MODELS.get("bedrock", []) if model_list: - print(f" Using {len(model_list)} curated models (live discovery unavailable)") + print( + f" Using {len(model_list)} curated models (live discovery unavailable)" + ) else: - print(" No models found. Check IAM permissions for bedrock:ListFoundationModels.") + print( + " No models found. Check IAM permissions for bedrock:ListFoundationModels." + ) return # 4. Model selection @@ -3048,11 +3309,22 @@ def _model_flow_bedrock(config, current_model=""): def _model_flow_api_key_provider(config, provider_id, current_model=""): """Generic flow for API-key providers (z.ai, MiniMax, OpenCode, etc.).""" from hermes_cli.auth import ( - PROVIDER_REGISTRY, _prompt_model_selection, _save_model_choice, + PROVIDER_REGISTRY, + _prompt_model_selection, + _save_model_choice, deactivate_provider, ) - from hermes_cli.config import get_env_value, save_env_value, load_config, save_config - from hermes_cli.models import fetch_api_models, opencode_model_api_mode, normalize_opencode_model_id + from hermes_cli.config import ( + get_env_value, + save_env_value, + load_config, + save_config, + ) + from hermes_cli.models import ( + fetch_api_models, + opencode_model_api_mode, + normalize_opencode_model_id, + ) pconfig = PROVIDER_REGISTRY[provider_id] key_env = pconfig.api_key_env_vars[0] if pconfig.api_key_env_vars else "" @@ -3070,6 +3342,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): if key_env: try: import getpass + new_key = getpass.getpass(f"{key_env} (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -3097,7 +3370,9 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): override = "" if override and base_url_env: if not override.startswith(("http://", "https://")): - print(" Invalid URL — must start with http:// or https://. Keeping current value.") + print( + " Invalid URL — must start with http:// or https://. Keeping current value." + ) else: save_env_value(base_url_env, override) effective_base = override @@ -3110,8 +3385,11 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): # Ollama Cloud: dedicated merged discovery (live API + models.dev + disk cache) if provider_id == "ollama-cloud": from hermes_cli.models import fetch_ollama_cloud_models + api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "") - model_list = fetch_ollama_cloud_models(api_key=api_key_for_probe, base_url=effective_base) + model_list = fetch_ollama_cloud_models( + api_key=api_key_for_probe, base_url=effective_base + ) if model_list: print(f" Found {len(model_list)} model(s) from Ollama Cloud") else: @@ -3121,6 +3399,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): mdev_models: list = [] try: from agent.models_dev import list_agentic_models + mdev_models = list_agentic_models(provider_id) except Exception: pass @@ -3131,9 +3410,13 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): elif curated and len(curated) >= 8: # Curated list is substantial — use it directly, skip live probe model_list = curated - print(f" Showing {len(model_list)} curated models — use \"Enter custom model name\" for others.") + print( + f' Showing {len(model_list)} curated models — use "Enter custom model name" for others.' + ) else: - api_key_for_probe = existing_key or (get_env_value(key_env) if key_env else "") + api_key_for_probe = existing_key or ( + get_env_value(key_env) if key_env else "" + ) live_models = fetch_api_models(api_key_for_probe, effective_base) if live_models and len(live_models) >= len(curated): model_list = live_models @@ -3141,11 +3424,15 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""): else: model_list = curated if model_list: - print(f" Showing {len(model_list)} curated models — use \"Enter custom model name\" for others.") + print( + f' Showing {len(model_list)} curated models — use "Enter custom model name" for others.' + ) # else: no defaults either, will fall through to raw input if provider_id in {"opencode-zen", "opencode-go"}: - model_list = [normalize_opencode_model_id(provider_id, mid) for mid in model_list] + model_list = [ + normalize_opencode_model_id(provider_id, mid) for mid in model_list + ] current_model = normalize_opencode_model_id(provider_id, current_model) model_list = list(dict.fromkeys(mid for mid in model_list if mid)) @@ -3201,13 +3488,15 @@ def _run_anthropic_oauth_flow(save_env_value): except Exception: creds = None if creds and ( - is_claude_code_token_valid(creds) - or bool(creds.get("refreshToken")) + is_claude_code_token_valid(creds) or bool(creds.get("refreshToken")) ): use_anthropic_claude_code_credentials(save_fn=save_env_value) print(" ✓ Claude Code credentials linked.") from hermes_constants import display_hermes_home as _dhh_fn - print(f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env.") + + print( + f" Hermes will use Claude's credential store directly instead of copying a setup-token into {_dhh_fn()}/.env." + ) return True return False @@ -3230,7 +3519,10 @@ def _run_anthropic_oauth_flow(save_env_value): print() try: import getpass - manual_token = getpass.getpass(" Paste setup-token (or Enter to cancel): ").strip() + + manual_token = getpass.getpass( + " Paste setup-token (or Enter to cancel): " + ).strip() except (KeyboardInterrupt, EOFError): print() return False @@ -3258,6 +3550,7 @@ def _run_anthropic_oauth_flow(save_env_value): print() try: import getpass + token = getpass.getpass(" Setup-token (or Enter to cancel): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -3273,21 +3566,29 @@ def _run_anthropic_oauth_flow(save_env_value): def _model_flow_anthropic(config, current_model=""): """Flow for Anthropic provider — OAuth subscription, API key, or Claude Code creds.""" from hermes_cli.auth import ( - _prompt_model_selection, _save_model_choice, + _prompt_model_selection, + _save_model_choice, deactivate_provider, ) from hermes_cli.config import ( - save_env_value, load_config, save_config, + save_env_value, + load_config, + save_config, save_anthropic_api_key, ) from hermes_cli.models import _PROVIDER_MODELS # Check ALL credential sources from hermes_cli.auth import get_anthropic_key + existing_key = get_anthropic_key() cc_available = False try: - from agent.anthropic_adapter import read_claude_code_credentials, is_claude_code_token_valid + from agent.anthropic_adapter import ( + read_claude_code_credentials, + is_claude_code_token_valid, + ) + cc_creds = read_claude_code_credentials() if cc_creds and is_claude_code_token_valid(cc_creds): cc_available = True @@ -3344,6 +3645,7 @@ def _model_flow_anthropic(config, current_model=""): print() try: import getpass + api_key = getpass.getpass(" API key (sk-ant-...): ").strip() except (KeyboardInterrupt, EOFError): print() @@ -3394,60 +3696,70 @@ def _model_flow_anthropic(config, current_model=""): def cmd_login(args): """Authenticate Hermes CLI with a provider.""" from hermes_cli.auth import login_command + login_command(args) def cmd_logout(args): """Clear provider authentication.""" from hermes_cli.auth import logout_command + logout_command(args) def cmd_auth(args): """Manage pooled credentials.""" from hermes_cli.auth_commands import auth_command + auth_command(args) def cmd_status(args): """Show status of all components.""" from hermes_cli.status import show_status + show_status(args) def cmd_cron(args): """Cron job management.""" from hermes_cli.cron import cron_command + cron_command(args) def cmd_webhook(args): """Webhook subscription management.""" from hermes_cli.webhook import webhook_command + webhook_command(args) def cmd_doctor(args): """Check configuration and dependencies.""" from hermes_cli.doctor import run_doctor + run_doctor(args) def cmd_dump(args): """Dump setup summary for support/debugging.""" from hermes_cli.dump import run_dump + run_dump(args) def cmd_debug(args): """Debug tools (share report, etc.).""" from hermes_cli.debug import run_debug + run_debug(args) def cmd_config(args): """Configuration management.""" from hermes_cli.config import config_command + config_command(args) @@ -3455,15 +3767,18 @@ def cmd_backup(args): """Back up Hermes home directory to a zip file.""" if getattr(args, "quick", False): from hermes_cli.backup import run_quick_backup + run_quick_backup(args) else: from hermes_cli.backup import run_backup + run_backup(args) def cmd_import(args): """Restore a Hermes backup from a zip file.""" from hermes_cli.backup import run_import + run_import(args) @@ -3471,13 +3786,14 @@ def cmd_version(args): """Show version.""" print(f"Hermes Agent v{__version__} ({__release_date__})") print(f"Project: {PROJECT_ROOT}") - + # Show Python version print(f"Python: {sys.version.split()[0]}") - + # Check for key dependencies try: import openai + print(f"OpenAI SDK: {openai.__version__}") except ImportError: print("OpenAI SDK: Not installed") @@ -3486,6 +3802,7 @@ def cmd_version(args): try: from hermes_cli.banner import check_for_updates from hermes_cli.config import recommended_update_command + behind = check_for_updates() if behind and behind > 0: commits_word = "commit" if behind == 1 else "commits" @@ -3503,6 +3820,7 @@ def cmd_uninstall(args): """Uninstall Hermes Agent.""" _require_tty("uninstall") from hermes_cli.uninstall import run_uninstall + run_uninstall(args) @@ -3520,12 +3838,14 @@ def _clear_bytecode_cache(root: Path) -> int: for dirpath, dirnames, _ in os.walk(root): # Skip venv / node_modules / .git entirely dirnames[:] = [ - d for d in dirnames + d + for d in dirnames if d not in ("venv", ".venv", "node_modules", ".git", ".worktrees") ] if os.path.basename(dirpath) == "__pycache__": try: import shutil as _shutil + _shutil.rmtree(dirpath) removed += 1 except OSError: @@ -3566,6 +3886,7 @@ def _gateway_prompt(prompt_text: str, default: str = "", timeout: float = 300.0) # Poll for response import time as _time + deadline = _time.monotonic() + timeout while _time.monotonic() < deadline: if response_path.exists(): @@ -3598,6 +3919,7 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: if not (web_dir / "package.json").exists(): return True import shutil + npm = shutil.which("npm") if not npm: if fatal: @@ -3607,15 +3929,19 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: print("→ Building web UI...") r1 = subprocess.run([npm, "install", "--silent"], cwd=web_dir, capture_output=True) if r1.returncode != 0: - print(f" {'✗' if fatal else '⚠'} Web UI npm install failed" - + ("" if fatal else " (hermes web will not be available)")) + print( + f" {'✗' if fatal else '⚠'} Web UI npm install failed" + + ("" if fatal else " (hermes web will not be available)") + ) if fatal: print(" Run manually: cd web && npm install && npm run build") return False r2 = subprocess.run([npm, "run", "build"], cwd=web_dir, capture_output=True) if r2.returncode != 0: - print(f" {'✗' if fatal else '⚠'} Web UI build failed" - + ("" if fatal else " (hermes web will not be available)")) + print( + f" {'✗' if fatal else '⚠'} Web UI build failed" + + ("" if fatal else " (hermes web will not be available)") + ) if fatal: print(" Run manually: cd web && npm install && npm run build") return False @@ -3625,34 +3951,41 @@ def _build_web_ui(web_dir: Path, *, fatal: bool = False) -> bool: def _update_via_zip(args): """Update Hermes Agent by downloading a ZIP archive. - - Used on Windows when git file I/O is broken (antivirus, NTFS filter + + Used on Windows when git file I/O is broken (antivirus, NTFS filter drivers causing 'Invalid argument' errors on file creation). """ import shutil import tempfile import zipfile from urllib.request import urlretrieve - + branch = "main" - zip_url = f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip" - + zip_url = ( + f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip" + ) + print("→ Downloading latest version...") try: tmp_dir = tempfile.mkdtemp(prefix="hermes-update-") zip_path = os.path.join(tmp_dir, f"hermes-agent-{branch}.zip") urlretrieve(zip_url, zip_path) - + print("→ Extracting...") - with zipfile.ZipFile(zip_path, 'r') as zf: + with zipfile.ZipFile(zip_path, "r") as zf: # Validate paths to prevent zip-slip (path traversal) tmp_dir_real = os.path.realpath(tmp_dir) for member in zf.infolist(): member_path = os.path.realpath(os.path.join(tmp_dir, member.filename)) - if not member_path.startswith(tmp_dir_real + os.sep) and member_path != tmp_dir_real: - raise ValueError(f"Zip-slip detected: {member.filename} escapes extraction directory") + if ( + not member_path.startswith(tmp_dir_real + os.sep) + and member_path != tmp_dir_real + ): + raise ValueError( + f"Zip-slip detected: {member.filename} escapes extraction directory" + ) zf.extractall(tmp_dir) - + # GitHub ZIPs extract to hermes-agent-/ extracted = os.path.join(tmp_dir, f"hermes-agent-{branch}") if not os.path.isdir(extracted): @@ -3662,9 +3995,9 @@ def _update_via_zip(args): if os.path.isdir(candidate) and d != "__MACOSX": extracted = candidate break - + # Copy updated files over existing installation, preserving venv/node_modules/.git - preserve = {'venv', 'node_modules', '.git', '.env'} + preserve = {"venv", "node_modules", ".git", ".env"} update_count = 0 for item in os.listdir(extracted): if item in preserve: @@ -3678,12 +4011,12 @@ def _update_via_zip(args): else: shutil.copy2(src, dst) update_count += 1 - + print(f"✓ Updated {update_count} items from ZIP") - + # Cleanup shutil.rmtree(tmp_dir, ignore_errors=True) - + except Exception as e: print(f"✗ ZIP update failed: {e}") sys.exit(1) @@ -3691,13 +4024,16 @@ def _update_via_zip(args): # Clear stale bytecode after ZIP extraction removed = _clear_bytecode_cache(PROJECT_ROOT) if removed: - print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") - + print( + f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}" + ) + # Reinstall Python dependencies. Prefer .[all], but if one optional extra # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") import subprocess + uv_bin = shutil.which("uv") if uv_bin: uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} @@ -3709,7 +4045,12 @@ def _update_via_zip(args): # ensurepip before trying the editable install. pip_cmd = [sys.executable, "-m", "pip"] try: - subprocess.run(pip_cmd + ["--version"], cwd=PROJECT_ROOT, check=True, capture_output=True) + subprocess.run( + pip_cmd + ["--version"], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + ) except subprocess.CalledProcessError: subprocess.run( [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], @@ -3724,12 +4065,15 @@ def _update_via_zip(args): # Sync skills try: from tools.skills_sync import sync_skills + print("→ Syncing bundled skills...") result = sync_skills(quiet=True) if result["copied"]: print(f" + {len(result['copied'])} new: {', '.join(result['copied'])}") if result.get("updated"): - print(f" ↑ {len(result['updated'])} updated: {', '.join(result['updated'])}") + print( + f" ↑ {len(result['updated'])} updated: {', '.join(result['updated'])}" + ) if result.get("user_modified"): print(f" ~ {len(result['user_modified'])} user-modified (kept)") if result.get("cleaned"): @@ -3738,7 +4082,7 @@ def _update_via_zip(args): print(" ✓ Skills are up to date") except Exception: pass - + print() print("✓ Update complete!") @@ -3770,7 +4114,9 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st from datetime import datetime, timezone - stash_name = datetime.now(timezone.utc).strftime("hermes-update-autostash-%Y%m%d-%H%M%S") + stash_name = datetime.now(timezone.utc).strftime( + "hermes-update-autostash-%Y%m%d-%H%M%S" + ) print("→ Local changes detected — stashing before update...") subprocess.run( git_cmd + ["stash", "push", "--include-untracked", "-m", stash_name], @@ -3787,8 +4133,9 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st return stash_ref - -def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]: +def _resolve_stash_selector( + git_cmd: list[str], cwd: Path, stash_ref: str +) -> Optional[str]: stash_list = subprocess.run( git_cmd + ["stash", "list", "--format=%gd %H"], cwd=cwd, @@ -3803,15 +4150,19 @@ def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Op return None - -def _print_stash_cleanup_guidance(stash_ref: str, stash_selector: Optional[str] = None) -> None: - print(" Check `git status` first so you don't accidentally reapply the same change twice.") +def _print_stash_cleanup_guidance( + stash_ref: str, stash_selector: Optional[str] = None +) -> None: + print( + " Check `git status` first so you don't accidentally reapply the same change twice." + ) print(" Find the saved entry with: git stash list --format='%gd %H %s'") if stash_selector: print(f" Remove it with: git stash drop {stash_selector}") else: - print(f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}") - + print( + f" Look for commit {stash_ref}, then drop its selector with: git stash drop stash@{{N}}" + ) def _restore_stashed_changes( @@ -3824,7 +4175,9 @@ def _restore_stashed_changes( if prompt_user: print() print("⚠ Local changes were stashed before updating.") - print(" Restoring them may reapply local customizations onto the updated codebase.") + print( + " Restoring them may reapply local customizations onto the updated codebase." + ) print(" Review the result afterward if Hermes behaves unexpectedly.") print("Restore local changes now? [Y/n]") if input_fn is not None: @@ -3888,8 +4241,12 @@ def _restore_stashed_changes( stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref) if stash_selector is None: - print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.") - print(" The stash was left in place. You can remove it manually after checking the result.") + print( + "⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop." + ) + print( + " The stash was left in place. You can remove it manually after checking the result." + ) _print_stash_cleanup_guidance(stash_ref) else: drop = subprocess.run( @@ -3899,18 +4256,23 @@ def _restore_stashed_changes( text=True, ) if drop.returncode != 0: - print("⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry.") + print( + "⚠ Local changes were restored, but Hermes couldn't drop the saved stash entry." + ) if drop.stdout.strip(): print(drop.stdout.strip()) if drop.stderr.strip(): print(drop.stderr.strip()) - print(" The stash was left in place. You can remove it manually after checking the result.") + print( + " The stash was left in place. You can remove it manually after checking the result." + ) _print_stash_cleanup_guidance(stash_ref, stash_selector) print("⚠ Local changes were restored on top of the updated codebase.") print(" Review `git diff` / `git status` if Hermes behaves unexpectedly.") return True + # ========================================================================= # Fork detection and upstream management for `hermes update` # ========================================================================= @@ -4005,6 +4367,7 @@ def _count_commits_between(git_cmd: list[str], cwd: Path, base: str, head: str) def _should_skip_upstream_prompt() -> bool: """Check if user previously declined to add upstream.""" from hermes_constants import get_hermes_home + return (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).exists() @@ -4012,6 +4375,7 @@ def _mark_skip_upstream_prompt(): """Create marker file to skip future upstream prompts.""" try: from hermes_constants import get_hermes_home + (get_hermes_home() / SKIP_UPSTREAM_PROMPT_FILE).touch() except Exception: pass @@ -4056,7 +4420,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: print(" This means you may miss updates from NousResearch/hermes-agent.") print() try: - response = input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower() + response = ( + input("Add official repo as 'upstream' remote? [Y/n]: ").strip().lower() + ) except (EOFError, KeyboardInterrupt): print() response = "n" @@ -4064,13 +4430,17 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: if response in ("", "y", "yes"): print("→ Adding upstream remote...") if _add_upstream_remote(git_cmd, cwd): - print(" ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git") + print( + " ✓ Added upstream: https://github.com/NousResearch/hermes-agent.git" + ) has_upstream = True else: print(" ✗ Failed to add upstream remote. Skipping upstream sync.") return else: - print(" Skipped. Run 'git remote add upstream https://github.com/NousResearch/hermes-agent.git' to add later.") + print( + " Skipped. Run 'git remote add upstream https://github.com/NousResearch/hermes-agent.git' to add later." + ) _mark_skip_upstream_prompt() return @@ -4090,7 +4460,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: # Compare origin/main with upstream/main origin_ahead = _count_commits_between(git_cmd, cwd, "upstream/main", "origin/main") - upstream_ahead = _count_commits_between(git_cmd, cwd, "origin/main", "upstream/main") + upstream_ahead = _count_commits_between( + git_cmd, cwd, "origin/main", "upstream/main" + ) if origin_ahead < 0 or upstream_ahead < 0: print(" ✗ Could not compare branches. Skipping upstream sync.") @@ -4122,7 +4494,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: check=True, ) except subprocess.CalledProcessError: - print(" ✗ Failed to pull from upstream. You may need to resolve conflicts manually.") + print( + " ✗ Failed to pull from upstream. You may need to resolve conflicts manually." + ) return print(" ✓ Updated from upstream") @@ -4132,7 +4506,9 @@ def _sync_with_upstream_if_needed(git_cmd: list[str], cwd: Path) -> None: if _sync_fork_with_upstream(git_cmd, cwd): print(" ✓ Fork synced with upstream") else: - print(" ℹ Got updates from upstream but couldn't push to fork (no write access?)") + print( + " ℹ Got updates from upstream but couldn't push to fork (no write access?)" + ) print(" Your local repo is updated, but your fork on GitHub may be behind.") @@ -4146,6 +4522,7 @@ def _invalidate_update_cache(): homes = [] # Default profile home (Docker-aware — uses /opt/data in Docker) from hermes_constants import get_default_hermes_root + default_home = get_default_hermes_root() homes.append(default_home) # Named profiles under /profiles/ @@ -4173,6 +4550,7 @@ def _load_installable_optional_extras() -> list[str]: """ try: import tomllib + with (PROJECT_ROOT / "pyproject.toml").open("rb") as handle: project = tomllib.load(handle).get("project", {}) except Exception: @@ -4195,7 +4573,6 @@ def _load_installable_optional_extras() -> list[str]: return referenced - def _install_python_dependencies_with_optional_fallback( install_cmd_prefix: list[str], *, @@ -4211,7 +4588,9 @@ def _install_python_dependencies_with_optional_fallback( ) return except subprocess.CalledProcessError: - print(" ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually...") + print( + " ⚠ Optional extras failed, reinstalling base dependencies and retrying extras individually..." + ) subprocess.run( install_cmd_prefix + ["install", "-e", ".", "--quiet"], @@ -4235,9 +4614,13 @@ def _install_python_dependencies_with_optional_fallback( failed_extras.append(extra) if installed_extras: - print(f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}") + print( + f" ✓ Reinstalled optional extras individually: {', '.join(installed_extras)}" + ) if failed_extras: - print(f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}") + print( + f" ⚠ Skipped optional extras that still failed: {', '.join(failed_extras)}" + ) def _update_node_dependencies() -> None: @@ -4284,30 +4667,45 @@ def cmd_update(args): gateway_mode = getattr(args, "gateway", False) # In gateway mode, use file-based IPC for prompts instead of stdin - gw_input_fn = (lambda prompt, default="": _gateway_prompt(prompt, default)) if gateway_mode else None - + gw_input_fn = ( + (lambda prompt, default="": _gateway_prompt(prompt, default)) + if gateway_mode + else None + ) + print("⚕ Updating Hermes Agent...") print() - + # Try git-based update first, fall back to ZIP download on Windows # when git file I/O is broken (antivirus, NTFS filter drivers, etc.) use_zip_update = False - git_dir = PROJECT_ROOT / '.git' - + git_dir = PROJECT_ROOT / ".git" + if not git_dir.exists(): if sys.platform == "win32": use_zip_update = True else: print("✗ Not a git repository. Please reinstall:") - print(" curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash") + print( + " curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash" + ) sys.exit(1) - + # On Windows, git can fail with "unable to write loose object file: Invalid argument" # due to filesystem atomicity issues. Set the recommended workaround. if sys.platform == "win32" and git_dir.exists(): subprocess.run( - ["git", "-c", "windows.appendAtomically=false", "config", "windows.appendAtomically", "false"], - cwd=PROJECT_ROOT, check=False, capture_output=True + [ + "git", + "-c", + "windows.appendAtomically=false", + "config", + "windows.appendAtomically", + "false", + ], + cwd=PROJECT_ROOT, + check=False, + capture_output=True, ) # Build git command once — reused for fork detection and the update itself. @@ -4344,8 +4742,12 @@ def cmd_update(args): if "Could not resolve host" in stderr or "unable to access" in stderr: print("✗ Network error — cannot reach the remote repository.") print(f" {stderr.splitlines()[0]}" if stderr else "") - elif "Authentication failed" in stderr or "could not read Username" in stderr: - print("✗ Authentication failed — check your git credentials or SSH key.") + elif ( + "Authentication failed" in stderr or "could not read Username" in stderr + ): + print( + "✗ Authentication failed — check your git credentials or SSH key." + ) else: print(f"✗ Failed to fetch updates from origin.") if stderr: @@ -4367,7 +4769,11 @@ def cmd_update(args): # If user is on a non-main branch or detached HEAD, switch to main if current_branch != "main": - label = "detached HEAD" if current_branch == "HEAD" else f"branch '{current_branch}'" + label = ( + "detached HEAD" + if current_branch == "HEAD" + else f"branch '{current_branch}'" + ) print(f" ⚠ Currently on {label} — switching to main for update...") # Stash before checkout so uncommitted work isn't lost auto_stash_ref = _stash_local_changes_if_needed(git_cmd, PROJECT_ROOT) @@ -4400,14 +4806,19 @@ def cmd_update(args): # Restore stash and switch back to original branch if we moved if auto_stash_ref is not None: _restore_stashed_changes( - git_cmd, PROJECT_ROOT, auto_stash_ref, + git_cmd, + PROJECT_ROOT, + auto_stash_ref, prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) if current_branch not in ("main", "HEAD"): subprocess.run( git_cmd + ["checkout", current_branch], - cwd=PROJECT_ROOT, capture_output=True, text=True, check=False, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, ) print("✓ Already up to date!") return @@ -4427,7 +4838,9 @@ def cmd_update(args): # ff-only failed — local and remote have diverged (e.g. upstream # force-pushed or rebase). Since local changes are already # stashed, reset to match the remote exactly. - print(" ⚠ Fast-forward not possible (history diverged), resetting to match remote...") + print( + " ⚠ Fast-forward not possible (history diverged), resetting to match remote..." + ) reset_result = subprocess.run( git_cmd + ["reset", "--hard", f"origin/{branch}"], cwd=PROJECT_ROOT, @@ -4438,7 +4851,9 @@ def cmd_update(args): print(f"✗ Failed to reset to origin/{branch}.") if reset_result.stderr.strip(): print(f" {reset_result.stderr.strip()}") - print(" Try manually: git fetch origin && git reset --hard origin/main") + print( + " Try manually: git fetch origin && git reset --hard origin/main" + ) sys.exit(1) update_succeeded = True finally: @@ -4446,7 +4861,9 @@ def cmd_update(args): # Don't attempt stash restore if the code update itself failed — # working tree is in an unknown state. if not update_succeeded: - print(f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})") + print( + f" ℹ️ Local changes preserved in stash (ref: {auto_stash_ref})" + ) print(f" Restore manually with: git stash apply") else: _restore_stashed_changes( @@ -4456,7 +4873,7 @@ def cmd_update(args): prompt_user=prompt_for_restore, input_fn=gw_input_fn, ) - + _invalidate_update_cache() # Clear stale .pyc bytecode cache — prevents ImportError on gateway @@ -4464,12 +4881,14 @@ def cmd_update(args): # the old bytecode (e.g. get_hermes_home added to hermes_constants). removed = _clear_bytecode_cache(PROJECT_ROOT) if removed: - print(f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}") + print( + f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}" + ) # Fork upstream sync logic (only for main branch on forks) if is_fork and branch == "main": _sync_with_upstream_if_needed(git_cmd, PROJECT_ROOT) - + # Reinstall Python dependencies. Prefer .[all], but if one optional extra # breaks on this machine, keep base deps and reinstall the remaining extras # individually so update does not silently strip working capabilities. @@ -4477,7 +4896,9 @@ def cmd_update(args): uv_bin = shutil.which("uv") if uv_bin: uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")} - _install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env) + _install_python_dependencies_with_optional_fallback( + [uv_bin, "pip"], env=uv_env + ) else: # Use sys.executable to explicitly call the venv's pip module, # avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu. @@ -4485,7 +4906,12 @@ def cmd_update(args): # ensurepip before trying the editable install. pip_cmd = [sys.executable, "-m", "pip"] try: - subprocess.run(pip_cmd + ["--version"], cwd=PROJECT_ROOT, check=True, capture_output=True) + subprocess.run( + pip_cmd + ["--version"], + cwd=PROJECT_ROOT, + check=True, + capture_output=True, + ) except subprocess.CalledProcessError: subprocess.run( [sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"], @@ -4493,13 +4919,13 @@ def cmd_update(args): check=True, ) _install_python_dependencies_with_optional_fallback(pip_cmd) - + _update_node_dependencies() _build_web_ui(PROJECT_ROOT / "web") print() print("✓ Code updated!") - + # After git pull, source files on disk are newer than cached Python # modules in this process. Reload hermes_constants so that any lazy # import executed below (skills sync, gateway restart) sees new @@ -4507,20 +4933,24 @@ def cmd_update(args): try: import importlib import hermes_constants as _hc + importlib.reload(_hc) except Exception: pass # non-fatal — worst case a lazy import fails gracefully - + # Sync bundled skills (copies new, updates changed, respects user deletions) try: from tools.skills_sync import sync_skills + print() print("→ Syncing bundled skills...") result = sync_skills(quiet=True) if result["copied"]: print(f" + {len(result['copied'])} new: {', '.join(result['copied'])}") if result.get("updated"): - print(f" ↑ {len(result['updated'])} updated: {', '.join(result['updated'])}") + print( + f" ↑ {len(result['updated'])} updated: {', '.join(result['updated'])}" + ) if result.get("user_modified"): print(f" ~ {len(result['user_modified'])} user-modified (kept)") if result.get("cleaned"): @@ -4532,7 +4962,12 @@ def cmd_update(args): # Sync bundled skills to all other profiles try: - from hermes_cli.profiles import list_profiles, get_active_profile_name, seed_profile_skills + from hermes_cli.profiles import ( + list_profiles, + get_active_profile_name, + seed_profile_skills, + ) + active = get_active_profile_name() other_profiles = [p for p in list_profiles() if p.name != active] if other_profiles: @@ -4546,9 +4981,12 @@ def cmd_update(args): updated = len(r.get("updated", [])) modified = len(r.get("user_modified", [])) parts = [] - if copied: parts.append(f"+{copied} new") - if updated: parts.append(f"↑{updated} updated") - if modified: parts.append(f"~{modified} user-modified") + if copied: + parts.append(f"+{copied} new") + if updated: + parts.append(f"↑{updated} updated") + if modified: + parts.append(f"~{modified} user-modified") status = ", ".join(parts) if parts else "up to date" else: status = "sync failed" @@ -4561,6 +4999,7 @@ def cmd_update(args): # Sync Honcho host blocks to all profiles try: from plugins.memory.honcho.cli import sync_honcho_profiles_quiet + synced = sync_honcho_profiles_quiet() if synced: print(f"\n-> Honcho: synced {synced} profile(s)") @@ -4570,46 +5009,60 @@ def cmd_update(args): # Check for config migrations print() print("→ Checking configuration for new options...") - + from hermes_cli.config import ( - get_missing_env_vars, get_missing_config_fields, - check_config_version, migrate_config + get_missing_env_vars, + get_missing_config_fields, + check_config_version, + migrate_config, ) - + missing_env = get_missing_env_vars(required_only=True) missing_config = get_missing_config_fields() current_ver, latest_ver = check_config_version() - + needs_migration = missing_env or missing_config or current_ver < latest_ver - + if needs_migration: print() if missing_env: - print(f" ⚠️ {len(missing_env)} new required setting(s) need configuration") + print( + f" ⚠️ {len(missing_env)} new required setting(s) need configuration" + ) if missing_config: print(f" ℹ️ {len(missing_config)} new config option(s) available") - + print() if gateway_mode: - response = _gateway_prompt( - "Would you like to configure new options now? [Y/n]", "n" - ).strip().lower() + response = ( + _gateway_prompt( + "Would you like to configure new options now? [Y/n]", "n" + ) + .strip() + .lower() + ) elif not (sys.stdin.isatty() and sys.stdout.isatty()): print(" ℹ Non-interactive session — skipping config migration prompt.") - print(" Run 'hermes config migrate' later to apply any new config/env options.") + print( + " Run 'hermes config migrate' later to apply any new config/env options." + ) response = "n" else: try: - response = input("Would you like to configure them now? [Y/n]: ").strip().lower() + response = ( + input("Would you like to configure them now? [Y/n]: ") + .strip() + .lower() + ) except EOFError: response = "n" - - if response in ('', 'y', 'yes'): + + if response in ("", "y", "yes"): print() # In gateway mode, run auto-migrations only (no input() prompts # for API keys which would hang the detached process). results = migrate_config(interactive=not gateway_mode, quiet=False) - + if results["env_added"] or results["config_added"]: print() print("✓ Configuration updated!") @@ -4620,10 +5073,10 @@ def cmd_update(args): print("Skipped. Run 'hermes config migrate' later to configure.") else: print(" ✓ Configuration is up to date") - + print() print("✓ Update complete!") - + # Write exit code *before* the gateway restart attempt. # When running as ``hermes update --gateway`` (spawned by the gateway's # /update command), this process lives inside the gateway's systemd @@ -4643,13 +5096,15 @@ def cmd_update(args): _exit_code_path.write_text("0") except OSError: pass - + # Auto-restart ALL gateways after update. # The code update (git pull) is shared across all profiles, so every # running gateway needs restarting to pick up the new code. try: from hermes_cli.gateway import ( - is_macos, supports_systemd_services, _ensure_user_systemd_env, + is_macos, + supports_systemd_services, + _ensure_user_systemd_env, find_gateway_pids, _get_service_pids, ) @@ -4666,39 +5121,60 @@ def cmd_update(args): except Exception: pass - for scope, scope_cmd in [("user", ["systemctl", "--user"]), ("system", ["systemctl"])]: + for scope, scope_cmd in [ + ("user", ["systemctl", "--user"]), + ("system", ["systemctl"]), + ]: try: result = subprocess.run( - scope_cmd + ["list-units", "hermes-gateway*", "--plain", "--no-legend", "--no-pager"], - capture_output=True, text=True, timeout=10, + scope_cmd + + [ + "list-units", + "hermes-gateway*", + "--plain", + "--no-legend", + "--no-pager", + ], + capture_output=True, + text=True, + timeout=10, ) for line in result.stdout.strip().splitlines(): parts = line.split() if not parts: continue - unit = parts[0] # e.g. hermes-gateway.service or hermes-gateway-coder.service + unit = parts[ + 0 + ] # e.g. hermes-gateway.service or hermes-gateway-coder.service if not unit.endswith(".service"): continue svc_name = unit.removesuffix(".service") # Check if active check = subprocess.run( scope_cmd + ["is-active", svc_name], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if check.stdout.strip() == "active": restart = subprocess.run( scope_cmd + ["restart", svc_name], - capture_output=True, text=True, timeout=15, + capture_output=True, + text=True, + timeout=15, ) if restart.returncode == 0: # Verify the service actually survived the # restart. systemctl restart returns 0 even # if the new process crashes immediately. import time as _time + _time.sleep(3) verify = subprocess.run( scope_cmd + ["is-active", svc_name], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if verify.stdout.strip() == "active": restarted_services.append(svc_name) @@ -4706,15 +5182,21 @@ def cmd_update(args): # Retry once — transient startup failures # (stale module cache, import race) often # resolve on the second attempt. - print(f" ⚠ {svc_name} died after restart, retrying...") + print( + f" ⚠ {svc_name} died after restart, retrying..." + ) retry = subprocess.run( scope_cmd + ["restart", svc_name], - capture_output=True, text=True, timeout=15, + capture_output=True, + text=True, + timeout=15, ) _time.sleep(3) verify2 = subprocess.run( scope_cmd + ["is-active", svc_name], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if verify2.stdout.strip() == "active": restarted_services.append(svc_name) @@ -4726,19 +5208,28 @@ def cmd_update(args): f" Restart manually: systemctl {'--user ' if scope == 'user' else ''}restart {svc_name}" ) else: - print(f" ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}") + print( + f" ⚠ Failed to restart {svc_name}: {restart.stderr.strip()}" + ) except (FileNotFoundError, subprocess.TimeoutExpired): pass # --- Launchd services (macOS) --- if is_macos(): try: - from hermes_cli.gateway import launchd_restart, get_launchd_label, get_launchd_plist_path + from hermes_cli.gateway import ( + launchd_restart, + get_launchd_label, + get_launchd_plist_path, + ) + plist_path = get_launchd_plist_path() if plist_path.exists(): check = subprocess.run( ["launchctl", "list", get_launchd_label()], - capture_output=True, text=True, timeout=5, + capture_output=True, + text=True, + timeout=5, ) if check.returncode == 0: try: @@ -4755,7 +5246,9 @@ def cmd_update(args): # Exclude PIDs that belong to just-restarted services so we don't # immediately kill the process that systemd/launchd just spawned. service_pids = _get_service_pids() - manual_pids = find_gateway_pids(exclude_pids=service_pids, all_profiles=True) + manual_pids = find_gateway_pids( + exclude_pids=service_pids, all_profiles=True + ) for pid in manual_pids: try: os.kill(pid, _signal.SIGTERM) @@ -4772,7 +5265,9 @@ def cmd_update(args): print(" Restart manually: hermes gateway run") # Also restart for each profile if needed if len(killed_pids) > 1: - print(" (or: hermes -p gateway run for each profile)") + print( + " (or: hermes -p gateway run for each profile)" + ) if not restarted_services and not killed_pids: # No gateways were running — nothing to do @@ -4780,11 +5275,11 @@ def cmd_update(args): except Exception as e: logger.debug("Gateway restart during update failed: %s", e) - + print() print("Tip: You can now select a provider and model:") print(" hermes model # Select provider and model") - + except subprocess.CalledProcessError as e: if sys.platform == "win32": print(f"⚠ Git update failed: {e}") @@ -4808,12 +5303,41 @@ def _coalesce_session_name_args(argv: list) -> list: or a known top-level subcommand. """ _SUBCOMMANDS = { - "chat", "model", "gateway", "setup", "whatsapp", "login", "logout", "auth", - "status", "cron", "doctor", "config", "pairing", "skills", "tools", - "mcp", "sessions", "insights", "version", "update", "uninstall", - "profile", "dashboard", - "honcho", "claw", "plugins", "acp", - "webhook", "memory", "dump", "debug", "backup", "import", "completion", "logs", + "chat", + "model", + "gateway", + "setup", + "whatsapp", + "login", + "logout", + "auth", + "status", + "cron", + "doctor", + "config", + "pairing", + "skills", + "tools", + "mcp", + "sessions", + "insights", + "version", + "update", + "uninstall", + "profile", + "dashboard", + "honcho", + "claw", + "plugins", + "acp", + "webhook", + "memory", + "dump", + "debug", + "backup", + "import", + "completion", + "logs", } _SESSION_FLAGS = {"-c", "--continue", "-r", "--resume"} @@ -4826,7 +5350,11 @@ def _coalesce_session_name_args(argv: list) -> list: i += 1 # Collect subsequent non-flag, non-subcommand tokens as one name parts: list = [] - while i < len(argv) and not argv[i].startswith("-") and argv[i] not in _SUBCOMMANDS: + while ( + i < len(argv) + and not argv[i].startswith("-") + and argv[i] not in _SUBCOMMANDS + ): parts.append(argv[i]) i += 1 if parts: @@ -4840,10 +5368,17 @@ def _coalesce_session_name_args(argv: list) -> list: def cmd_profile(args): """Profile management — create, delete, list, switch, alias.""" from hermes_cli.profiles import ( - list_profiles, create_profile, delete_profile, seed_profile_skills, - set_active_profile, get_active_profile_name, - check_alias_collision, create_wrapper_script, remove_wrapper_script, - _is_wrapper_dir_in_path, _get_wrapper_dir, + list_profiles, + create_profile, + delete_profile, + seed_profile_skills, + set_active_profile, + get_active_profile_name, + check_alias_collision, + create_wrapper_script, + remove_wrapper_script, + _is_wrapper_dir_in_path, + _get_wrapper_dir, ) from hermes_constants import display_hermes_home @@ -4860,8 +5395,13 @@ def cmd_profile(args): for p in profiles: if p.name == profile_name or (profile_name == "default" and p.is_default): if p.model: - print(f"Model: {p.model}" + (f" ({p.provider})" if p.provider else "")) - print(f"Gateway: {'running' if p.gateway_running else 'stopped'}") + print( + f"Model: {p.model}" + + (f" ({p.provider})" if p.provider else "") + ) + print( + f"Gateway: {'running' if p.gateway_running else 'stopped'}" + ) print(f"Skills: {p.skill_count} installed") if p.alias_path: print(f"Alias: {p.name} → hermes -p {p.name}") @@ -4882,7 +5422,11 @@ def cmd_profile(args): print(f" {'─' * 15} {'─' * 27} {'─' * 11} {'─' * 12}") for p in profiles: - marker = " ◆" if (p.name == active or (active == "default" and p.is_default)) else " " + marker = ( + " ◆" + if (p.name == active or (active == "default" and p.is_default)) + else " " + ) name = p.name model = (p.model or "—")[:26] gw = "running" if p.gateway_running else "stopped" @@ -4923,7 +5467,9 @@ def cmd_profile(args): print(f"\nProfile '{name}' created at {profile_dir}") if clone or clone_all: - source_label = getattr(args, "clone_from", None) or get_active_profile_name() + source_label = ( + getattr(args, "clone_from", None) or get_active_profile_name() + ) if clone_all: print(f"Full copy from {source_label}.") else: @@ -4933,6 +5479,7 @@ def cmd_profile(args): if clone or clone_all: try: from plugins.memory.honcho.cli import clone_honcho_for_profile + if clone_honcho_for_profile(name): print(f"Honcho config cloned (peer: {name})") except Exception: @@ -4945,14 +5492,20 @@ def cmd_profile(args): copied = len(result.get("copied", [])) print(f"{copied} bundled skills synced.") else: - print("⚠ Skills could not be seeded. Run `{} update` to retry.".format(name)) + print( + "⚠ Skills could not be seeded. Run `{} update` to retry.".format( + name + ) + ) # Create wrapper alias if not no_alias: collision = check_alias_collision(name) if collision: print(f"\n⚠ Cannot create alias '{name}' — {collision}") - print(f" Choose a custom alias: hermes profile alias {name} --name ") + print( + f" Choose a custom alias: hermes profile alias {name} --name " + ) print(f" Or access via flag: hermes -p {name} chat") else: wrapper_path = create_wrapper_script(name) @@ -4960,7 +5513,9 @@ def cmd_profile(args): print(f"Wrapper created: {wrapper_path}") if not _is_wrapper_dir_in_path(): print(f"\n⚠ {_get_wrapper_dir()} is not in your PATH.") - print(f' Add to your shell config (~/.bashrc or ~/.zshrc):') + print( + f" Add to your shell config (~/.bashrc or ~/.zshrc):" + ) print(f' export PATH="$HOME/.local/bin:$PATH"') # Profile dir for display @@ -4978,7 +5533,9 @@ def cmd_profile(args): print(f"\n Edit {profile_dir_display}/.env for different API keys") print(f" Edit {profile_dir_display}/SOUL.md for different personality") else: - print(f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first,") + print( + f"\n ⚠ This profile has no API keys yet. Run '{name} setup' first," + ) print(f" or it will inherit keys from your shell environment.") print(f" Edit {profile_dir_display}/SOUL.md to customize personality") print() @@ -4998,7 +5555,14 @@ def cmd_profile(args): elif action == "show": name = args.profile_name - from hermes_cli.profiles import get_profile_dir, profile_exists, _read_config_model, _check_gateway_running, _count_skills + from hermes_cli.profiles import ( + get_profile_dir, + profile_exists, + _read_config_model, + _check_gateway_running, + _count_skills, + ) + if not profile_exists(name): print(f"Error: Profile '{name}' does not exist.") sys.exit(1) @@ -5014,8 +5578,12 @@ def cmd_profile(args): print(f"Model: {model}" + (f" ({provider})" if provider else "")) print(f"Gateway: {'running' if gw else 'stopped'}") print(f"Skills: {skills}") - print(f".env: {'exists' if (profile_dir / '.env').exists() else 'not configured'}") - print(f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}") + print( + f".env: {'exists' if (profile_dir / '.env').exists() else 'not configured'}" + ) + print( + f"SOUL.md: {'exists' if (profile_dir / 'SOUL.md').exists() else 'not configured'}" + ) if wrapper.exists(): print(f"Alias: {wrapper}") print() @@ -5026,6 +5594,7 @@ def cmd_profile(args): custom_name = getattr(args, "alias_name", None) from hermes_cli.profiles import profile_exists + if not profile_exists(name): print(f"Error: Profile '{name}' does not exist.") sys.exit(1) @@ -5053,6 +5622,7 @@ def cmd_profile(args): elif action == "rename": from hermes_cli.profiles import rename_profile + try: new_dir = rename_profile(args.old_name, args.new_name) print(f"\nProfile renamed: {args.old_name} → {args.new_name}") @@ -5063,6 +5633,7 @@ def cmd_profile(args): elif action == "export": from hermes_cli.profiles import export_profile + name = args.profile_name output = args.output or f"{name}.tar.gz" try: @@ -5074,8 +5645,11 @@ def cmd_profile(args): elif action == "import": from hermes_cli.profiles import import_profile + try: - profile_dir = import_profile(args.archive, name=getattr(args, "import_name", None)) + profile_dir = import_profile( + args.archive, name=getattr(args, "import_name", None) + ) name = profile_dir.name print(f"✓ Imported profile '{name}' at {profile_dir}") @@ -5105,6 +5679,7 @@ def cmd_dashboard(args): sys.exit(1) from hermes_cli.web_server import start_server + start_server( host=args.host, port=args.port, @@ -5116,6 +5691,7 @@ def cmd_dashboard(args): def cmd_completion(args, parser=None): """Print shell completion script.""" from hermes_cli.completion import generate_bash, generate_zsh, generate_fish + shell = getattr(args, "shell", "bash") if shell == "zsh": print(generate_zsh(parser)) @@ -5185,58 +5761,60 @@ Examples: For more help on a command: hermes --help -""" +""", ) - + parser.add_argument( - "--version", "-V", - action="store_true", - help="Show version and exit" + "--version", "-V", action="store_true", help="Show version and exit" ) parser.add_argument( - "--resume", "-r", + "--resume", + "-r", metavar="SESSION", default=None, - help="Resume a previous session by ID or title" + help="Resume a previous session by ID or title", ) parser.add_argument( - "--continue", "-c", + "--continue", + "-c", dest="continue_last", nargs="?", const=True, default=None, metavar="SESSION_NAME", - help="Resume a session by name, or the most recent if no name given" + help="Resume a session by name, or the most recent if no name given", ) parser.add_argument( - "--worktree", "-w", + "--worktree", + "-w", action="store_true", default=False, - help="Run in an isolated git worktree (for parallel agents)" + help="Run in an isolated git worktree (for parallel agents)", ) parser.add_argument( - "--skills", "-s", + "--skills", + "-s", action="append", default=None, - help="Preload one or more skills for the session (repeat flag or comma-separate)" + help="Preload one or more skills for the session (repeat flag or comma-separate)", ) parser.add_argument( "--yolo", action="store_true", default=False, - help="Bypass all dangerous command approval prompts (use at your own risk)" + help="Bypass all dangerous command approval prompts (use at your own risk)", ) parser.add_argument( "--pass-session-id", action="store_true", default=False, - help="Include the session ID in the agent's system prompt" + help="Include the session ID in the agent's system prompt", ) parser.add_argument( "--tui", action="store_true", default=False, - help="Launch the modern TUI instead of the classic REPL" + help="Launch the modern TUI instead of the classic REPL", ) parser.add_argument( "--dev", @@ -5247,109 +5825,128 @@ For more help on a command: ) subparsers = parser.add_subparsers(dest="command", help="Command to run") - + # ========================================================================= # chat command # ========================================================================= chat_parser = subparsers.add_parser( "chat", help="Interactive chat with the agent", - description="Start an interactive chat session with Hermes Agent" + description="Start an interactive chat session with Hermes Agent", ) chat_parser.add_argument( - "-q", "--query", - help="Single query (non-interactive mode)" + "-q", "--query", help="Single query (non-interactive mode)" ) chat_parser.add_argument( - "--image", - help="Optional local image path to attach to a single query" + "--image", help="Optional local image path to attach to a single query" ) chat_parser.add_argument( - "-m", "--model", - help="Model to use (e.g., anthropic/claude-sonnet-4)" + "-m", "--model", help="Model to use (e.g., anthropic/claude-sonnet-4)" ) chat_parser.add_argument( - "-t", "--toolsets", - help="Comma-separated toolsets to enable" + "-t", "--toolsets", help="Comma-separated toolsets to enable" ) chat_parser.add_argument( - "-s", "--skills", + "-s", + "--skills", action="append", default=argparse.SUPPRESS, - help="Preload one or more skills for the session (repeat flag or comma-separate)" + help="Preload one or more skills for the session (repeat flag or comma-separate)", ) chat_parser.add_argument( "--provider", - choices=["auto", "openrouter", "nous", "openai-codex", "copilot-acp", "copilot", "anthropic", "gemini", "xai", "ollama-cloud", "huggingface", "zai", "kimi-coding", "kimi-coding-cn", "minimax", "minimax-cn", "kilocode", "xiaomi", "arcee"], + choices=[ + "auto", + "openrouter", + "nous", + "openai-codex", + "copilot-acp", + "copilot", + "anthropic", + "gemini", + "xai", + "ollama-cloud", + "huggingface", + "zai", + "kimi-coding", + "kimi-coding-cn", + "minimax", + "minimax-cn", + "kilocode", + "xiaomi", + "arcee", + ], default=None, - help="Inference provider (default: auto)" + help="Inference provider (default: auto)", ) chat_parser.add_argument( - "-v", "--verbose", + "-v", "--verbose", action="store_true", help="Verbose output" + ) + chat_parser.add_argument( + "-Q", + "--quiet", action="store_true", - help="Verbose output" + help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info.", ) chat_parser.add_argument( - "-Q", "--quiet", - action="store_true", - help="Quiet mode for programmatic use: suppress banner, spinner, and tool previews. Only output the final response and session info." - ) - chat_parser.add_argument( - "--resume", "-r", + "--resume", + "-r", metavar="SESSION_ID", default=argparse.SUPPRESS, - help="Resume a previous session by ID (shown on exit)" + help="Resume a previous session by ID (shown on exit)", ) chat_parser.add_argument( - "--continue", "-c", + "--continue", + "-c", dest="continue_last", nargs="?", const=True, default=argparse.SUPPRESS, metavar="SESSION_NAME", - help="Resume a session by name, or the most recent if no name given" + help="Resume a session by name, or the most recent if no name given", ) chat_parser.add_argument( - "--worktree", "-w", + "--worktree", + "-w", action="store_true", default=argparse.SUPPRESS, - help="Run in an isolated git worktree (for parallel agents on the same repo)" + help="Run in an isolated git worktree (for parallel agents on the same repo)", ) chat_parser.add_argument( "--checkpoints", action="store_true", default=False, - help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)" + help="Enable filesystem checkpoints before destructive file operations (use /rollback to restore)", ) chat_parser.add_argument( "--max-turns", type=int, default=None, metavar="N", - help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)" + help="Maximum tool-calling iterations per conversation turn (default: 90, or agent.max_turns in config)", ) chat_parser.add_argument( "--yolo", action="store_true", default=argparse.SUPPRESS, - help="Bypass all dangerous command approval prompts (use at your own risk)" + help="Bypass all dangerous command approval prompts (use at your own risk)", ) chat_parser.add_argument( "--pass-session-id", action="store_true", default=argparse.SUPPRESS, - help="Include the session ID in the agent's system prompt" + help="Include the session ID in the agent's system prompt", ) chat_parser.add_argument( "--source", default=None, - help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists." + help="Session source tag for filtering (default: cli). Use 'tool' for third-party integrations that should not appear in user session lists.", ) chat_parser.add_argument( "--tui", action="store_true", default=False, - help="Launch the modern TUI instead of the classic REPL" + help="Launch the modern TUI instead of the classic REPL", ) chat_parser.add_argument( "--dev", @@ -5366,45 +5963,42 @@ For more help on a command: model_parser = subparsers.add_parser( "model", help="Select default model and provider", - description="Interactively select your inference provider and default model" + description="Interactively select your inference provider and default model", ) model_parser.add_argument( "--portal-url", - help="Portal base URL for Nous login (default: production portal)" + help="Portal base URL for Nous login (default: production portal)", ) model_parser.add_argument( "--inference-url", - help="Inference API base URL for Nous login (default: production inference API)" + help="Inference API base URL for Nous login (default: production inference API)", ) model_parser.add_argument( "--client-id", default=None, - help="OAuth client id to use for Nous login (default: hermes-cli)" + help="OAuth client id to use for Nous login (default: hermes-cli)", ) model_parser.add_argument( - "--scope", - default=None, - help="OAuth scope to request for Nous login" + "--scope", default=None, help="OAuth scope to request for Nous login" ) model_parser.add_argument( "--no-browser", action="store_true", - help="Do not attempt to open the browser automatically during Nous login" + help="Do not attempt to open the browser automatically during Nous login", ) model_parser.add_argument( "--timeout", type=float, default=15.0, - help="HTTP request timeout in seconds for Nous login (default: 15)" + help="HTTP request timeout in seconds for Nous login (default: 15)", ) model_parser.add_argument( - "--ca-bundle", - help="Path to CA bundle PEM file for Nous TLS verification" + "--ca-bundle", help="Path to CA bundle PEM file for Nous TLS verification" ) model_parser.add_argument( "--insecure", action="store_true", - help="Disable TLS verification for Nous login (testing only)" + help="Disable TLS verification for Nous login (testing only)", ) model_parser.set_defaults(func=cmd_model) @@ -5414,54 +6008,113 @@ For more help on a command: gateway_parser = subparsers.add_parser( "gateway", help="Messaging gateway management", - description="Manage the messaging gateway (Telegram, Discord, WhatsApp)" + description="Manage the messaging gateway (Telegram, Discord, WhatsApp)", ) gateway_subparsers = gateway_parser.add_subparsers(dest="gateway_command") - + # gateway run (default) - gateway_run = gateway_subparsers.add_parser("run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)") - gateway_run.add_argument("-v", "--verbose", action="count", default=0, - help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)") - gateway_run.add_argument("-q", "--quiet", action="store_true", - help="Suppress all stderr log output") - gateway_run.add_argument("--replace", action="store_true", - help="Replace any existing gateway instance (useful for systemd)") - + gateway_run = gateway_subparsers.add_parser( + "run", help="Run gateway in foreground (recommended for WSL, Docker, Termux)" + ) + gateway_run.add_argument( + "-v", + "--verbose", + action="count", + default=0, + help="Increase stderr log verbosity (-v=INFO, -vv=DEBUG)", + ) + gateway_run.add_argument( + "-q", "--quiet", action="store_true", help="Suppress all stderr log output" + ) + gateway_run.add_argument( + "--replace", + action="store_true", + help="Replace any existing gateway instance (useful for systemd)", + ) + # gateway start - gateway_start = gateway_subparsers.add_parser("start", help="Start the installed systemd/launchd background service") - gateway_start.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service") - gateway_start.add_argument("--all", action="store_true", help="Kill ALL stale gateway processes across all profiles before starting") - + gateway_start = gateway_subparsers.add_parser( + "start", help="Start the installed systemd/launchd background service" + ) + gateway_start.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + gateway_start.add_argument( + "--all", + action="store_true", + help="Kill ALL stale gateway processes across all profiles before starting", + ) + # gateway stop gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service") - gateway_stop.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service") - gateway_stop.add_argument("--all", action="store_true", help="Stop ALL gateway processes across all profiles") - + gateway_stop.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + gateway_stop.add_argument( + "--all", + action="store_true", + help="Stop ALL gateway processes across all profiles", + ) + # gateway restart - gateway_restart = gateway_subparsers.add_parser("restart", help="Restart gateway service") - gateway_restart.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service") - gateway_restart.add_argument("--all", action="store_true", help="Kill ALL gateway processes across all profiles before restarting") - + gateway_restart = gateway_subparsers.add_parser( + "restart", help="Restart gateway service" + ) + gateway_restart.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + gateway_restart.add_argument( + "--all", + action="store_true", + help="Kill ALL gateway processes across all profiles before restarting", + ) + # gateway status gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status") gateway_status.add_argument("--deep", action="store_true", help="Deep status check") - gateway_status.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service") - + gateway_status.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) + # gateway install - gateway_install = gateway_subparsers.add_parser("install", help="Install gateway as a systemd/launchd background service") + gateway_install = gateway_subparsers.add_parser( + "install", help="Install gateway as a systemd/launchd background service" + ) gateway_install.add_argument("--force", action="store_true", help="Force reinstall") - gateway_install.add_argument("--system", action="store_true", help="Install as a Linux system-level service (starts at boot)") - gateway_install.add_argument("--run-as-user", dest="run_as_user", help="User account the Linux system service should run as") - + gateway_install.add_argument( + "--system", + action="store_true", + help="Install as a Linux system-level service (starts at boot)", + ) + gateway_install.add_argument( + "--run-as-user", + dest="run_as_user", + help="User account the Linux system service should run as", + ) + # gateway uninstall - gateway_uninstall = gateway_subparsers.add_parser("uninstall", help="Uninstall gateway service") - gateway_uninstall.add_argument("--system", action="store_true", help="Target the Linux system-level gateway service") + gateway_uninstall = gateway_subparsers.add_parser( + "uninstall", help="Uninstall gateway service" + ) + gateway_uninstall.add_argument( + "--system", + action="store_true", + help="Target the Linux system-level gateway service", + ) # gateway setup gateway_subparsers.add_parser("setup", help="Configure messaging platforms") gateway_parser.set_defaults(func=cmd_gateway) - + # ========================================================================= # setup command # ========================================================================= @@ -5469,24 +6122,22 @@ For more help on a command: "setup", help="Interactive setup wizard", description="Configure Hermes Agent with an interactive wizard. " - "Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent" + "Run a specific section: hermes setup model|tts|terminal|gateway|tools|agent", ) setup_parser.add_argument( "section", nargs="?", choices=["model", "tts", "terminal", "gateway", "tools", "agent"], default=None, - help="Run a specific setup section instead of the full wizard" + help="Run a specific setup section instead of the full wizard", ) setup_parser.add_argument( "--non-interactive", action="store_true", - help="Non-interactive mode (use defaults/env vars)" + help="Non-interactive mode (use defaults/env vars)", ) setup_parser.add_argument( - "--reset", - action="store_true", - help="Reset configuration to defaults" + "--reset", action="store_true", help="Reset configuration to defaults" ) setup_parser.set_defaults(func=cmd_setup) @@ -5496,7 +6147,7 @@ For more help on a command: whatsapp_parser = subparsers.add_parser( "whatsapp", help="Set up WhatsApp integration", - description="Configure WhatsApp and pair via QR code" + description="Configure WhatsApp and pair via QR code", ) whatsapp_parser.set_defaults(func=cmd_whatsapp) @@ -5506,51 +6157,43 @@ For more help on a command: login_parser = subparsers.add_parser( "login", help="Authenticate with an inference provider", - description="Run OAuth device authorization flow for Hermes CLI" + description="Run OAuth device authorization flow for Hermes CLI", ) login_parser.add_argument( "--provider", choices=["nous", "openai-codex"], default=None, - help="Provider to authenticate with (default: nous)" + help="Provider to authenticate with (default: nous)", ) login_parser.add_argument( - "--portal-url", - help="Portal base URL (default: production portal)" + "--portal-url", help="Portal base URL (default: production portal)" ) login_parser.add_argument( "--inference-url", - help="Inference API base URL (default: production inference API)" + help="Inference API base URL (default: production inference API)", ) login_parser.add_argument( - "--client-id", - default=None, - help="OAuth client id to use (default: hermes-cli)" - ) - login_parser.add_argument( - "--scope", - default=None, - help="OAuth scope to request" + "--client-id", default=None, help="OAuth client id to use (default: hermes-cli)" ) + login_parser.add_argument("--scope", default=None, help="OAuth scope to request") login_parser.add_argument( "--no-browser", action="store_true", - help="Do not attempt to open the browser automatically" + help="Do not attempt to open the browser automatically", ) login_parser.add_argument( "--timeout", type=float, default=15.0, - help="HTTP request timeout in seconds (default: 15)" + help="HTTP request timeout in seconds (default: 15)", ) login_parser.add_argument( - "--ca-bundle", - help="Path to CA bundle PEM file for TLS verification" + "--ca-bundle", help="Path to CA bundle PEM file for TLS verification" ) login_parser.add_argument( "--insecure", action="store_true", - help="Disable TLS verification (testing only)" + help="Disable TLS verification (testing only)", ) login_parser.set_defaults(func=cmd_login) @@ -5560,13 +6203,13 @@ For more help on a command: logout_parser = subparsers.add_parser( "logout", help="Clear authentication for an inference provider", - description="Remove stored credentials and reset provider config" + description="Remove stored credentials and reset provider config", ) logout_parser.add_argument( "--provider", choices=["nous", "openai-codex"], default=None, - help="Provider to log out from (default: active provider)" + help="Provider to log out from (default: active provider)", ) logout_parser.set_defaults(func=cmd_logout) @@ -5576,24 +6219,50 @@ For more help on a command: ) auth_subparsers = auth_parser.add_subparsers(dest="auth_action") auth_add = auth_subparsers.add_parser("add", help="Add a pooled credential") - auth_add.add_argument("provider", help="Provider id (for example: anthropic, openai-codex, openrouter)") - auth_add.add_argument("--type", dest="auth_type", choices=["oauth", "api-key", "api_key"], help="Credential type to add") + auth_add.add_argument( + "provider", + help="Provider id (for example: anthropic, openai-codex, openrouter)", + ) + auth_add.add_argument( + "--type", + dest="auth_type", + choices=["oauth", "api-key", "api_key"], + help="Credential type to add", + ) auth_add.add_argument("--label", help="Optional display label") - auth_add.add_argument("--api-key", help="API key value (otherwise prompted securely)") + auth_add.add_argument( + "--api-key", help="API key value (otherwise prompted securely)" + ) auth_add.add_argument("--portal-url", help="Nous portal base URL") auth_add.add_argument("--inference-url", help="Nous inference base URL") auth_add.add_argument("--client-id", help="OAuth client id") auth_add.add_argument("--scope", help="OAuth scope override") - auth_add.add_argument("--no-browser", action="store_true", help="Do not auto-open a browser for OAuth login") - auth_add.add_argument("--timeout", type=float, help="OAuth/network timeout in seconds") - auth_add.add_argument("--insecure", action="store_true", help="Disable TLS verification for OAuth login") + auth_add.add_argument( + "--no-browser", + action="store_true", + help="Do not auto-open a browser for OAuth login", + ) + auth_add.add_argument( + "--timeout", type=float, help="OAuth/network timeout in seconds" + ) + auth_add.add_argument( + "--insecure", + action="store_true", + help="Disable TLS verification for OAuth login", + ) auth_add.add_argument("--ca-bundle", help="Custom CA bundle for OAuth login") auth_list = auth_subparsers.add_parser("list", help="List pooled credentials") auth_list.add_argument("provider", nargs="?", help="Optional provider filter") - auth_remove = auth_subparsers.add_parser("remove", help="Remove a pooled credential by index, id, or label") + auth_remove = auth_subparsers.add_parser( + "remove", help="Remove a pooled credential by index, id, or label" + ) auth_remove.add_argument("provider", help="Provider id") - auth_remove.add_argument("target", help="Credential index, entry id, or exact label") - auth_reset = auth_subparsers.add_parser("reset", help="Clear exhaustion status for all credentials for a provider") + auth_remove.add_argument( + "target", help="Credential index, entry id, or exact label" + ) + auth_reset = auth_subparsers.add_parser( + "reset", help="Clear exhaustion status for all credentials for a provider" + ) auth_reset.add_argument("provider", help="Provider id") auth_parser.set_defaults(func=cmd_auth) @@ -5603,57 +6272,92 @@ For more help on a command: status_parser = subparsers.add_parser( "status", help="Show status of all components", - description="Display status of Hermes Agent components" + description="Display status of Hermes Agent components", ) status_parser.add_argument( - "--all", - action="store_true", - help="Show all details (redacted for sharing)" + "--all", action="store_true", help="Show all details (redacted for sharing)" ) status_parser.add_argument( - "--deep", - action="store_true", - help="Run deep checks (may take longer)" + "--deep", action="store_true", help="Run deep checks (may take longer)" ) status_parser.set_defaults(func=cmd_status) - + # ========================================================================= # cron command # ========================================================================= cron_parser = subparsers.add_parser( - "cron", - help="Cron job management", - description="Manage scheduled tasks" + "cron", help="Cron job management", description="Manage scheduled tasks" ) cron_subparsers = cron_parser.add_subparsers(dest="cron_command") - + # cron list cron_list = cron_subparsers.add_parser("list", help="List scheduled jobs") cron_list.add_argument("--all", action="store_true", help="Include disabled jobs") # cron create/add - cron_create = cron_subparsers.add_parser("create", aliases=["add"], help="Create a scheduled job") - cron_create.add_argument("schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'") - cron_create.add_argument("prompt", nargs="?", help="Optional self-contained prompt or task instruction") + cron_create = cron_subparsers.add_parser( + "create", aliases=["add"], help="Create a scheduled job" + ) + cron_create.add_argument( + "schedule", help="Schedule like '30m', 'every 2h', or '0 9 * * *'" + ) + cron_create.add_argument( + "prompt", nargs="?", help="Optional self-contained prompt or task instruction" + ) cron_create.add_argument("--name", help="Optional human-friendly job name") - cron_create.add_argument("--deliver", help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id") + cron_create.add_argument( + "--deliver", + help="Delivery target: origin, local, telegram, discord, signal, or platform:chat_id", + ) cron_create.add_argument("--repeat", type=int, help="Optional repeat count") - cron_create.add_argument("--skill", dest="skills", action="append", help="Attach a skill. Repeat to add multiple skills.") - cron_create.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run") + cron_create.add_argument( + "--skill", + dest="skills", + action="append", + help="Attach a skill. Repeat to add multiple skills.", + ) + cron_create.add_argument( + "--script", + help="Path to a Python script whose stdout is injected into the prompt each run", + ) # cron edit - cron_edit = cron_subparsers.add_parser("edit", help="Edit an existing scheduled job") + cron_edit = cron_subparsers.add_parser( + "edit", help="Edit an existing scheduled job" + ) cron_edit.add_argument("job_id", help="Job ID to edit") cron_edit.add_argument("--schedule", help="New schedule") cron_edit.add_argument("--prompt", help="New prompt/task instruction") cron_edit.add_argument("--name", help="New job name") cron_edit.add_argument("--deliver", help="New delivery target") cron_edit.add_argument("--repeat", type=int, help="New repeat count") - cron_edit.add_argument("--skill", dest="skills", action="append", help="Replace the job's skills with this set. Repeat to attach multiple skills.") - cron_edit.add_argument("--add-skill", dest="add_skills", action="append", help="Append a skill without replacing the existing list. Repeatable.") - cron_edit.add_argument("--remove-skill", dest="remove_skills", action="append", help="Remove a specific attached skill. Repeatable.") - cron_edit.add_argument("--clear-skills", action="store_true", help="Remove all attached skills from the job") - cron_edit.add_argument("--script", help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.") + cron_edit.add_argument( + "--skill", + dest="skills", + action="append", + help="Replace the job's skills with this set. Repeat to attach multiple skills.", + ) + cron_edit.add_argument( + "--add-skill", + dest="add_skills", + action="append", + help="Append a skill without replacing the existing list. Repeatable.", + ) + cron_edit.add_argument( + "--remove-skill", + dest="remove_skills", + action="append", + help="Remove a specific attached skill. Repeatable.", + ) + cron_edit.add_argument( + "--clear-skills", + action="store_true", + help="Remove all attached skills from the job", + ) + cron_edit.add_argument( + "--script", + help="Path to a Python script whose stdout is injected into the prompt each run. Pass empty string to clear.", + ) # lifecycle actions cron_pause = cron_subparsers.add_parser("pause", help="Pause a scheduled job") @@ -5662,10 +6366,14 @@ For more help on a command: cron_resume = cron_subparsers.add_parser("resume", help="Resume a paused job") cron_resume.add_argument("job_id", help="Job ID to resume") - cron_run = cron_subparsers.add_parser("run", help="Run a job on the next scheduler tick") + cron_run = cron_subparsers.add_parser( + "run", help="Run a job on the next scheduler tick" + ) cron_run.add_argument("job_id", help="Job ID to trigger") - cron_remove = cron_subparsers.add_parser("remove", aliases=["rm", "delete"], help="Remove a scheduled job") + cron_remove = cron_subparsers.add_parser( + "remove", aliases=["rm", "delete"], help="Remove a scheduled job" + ) cron_remove.add_argument("job_id", help="Job ID to remove") # cron status @@ -5686,24 +6394,50 @@ For more help on a command: ) webhook_subparsers = webhook_parser.add_subparsers(dest="webhook_action") - wh_sub = webhook_subparsers.add_parser("subscribe", aliases=["add"], help="Create a webhook subscription") + wh_sub = webhook_subparsers.add_parser( + "subscribe", aliases=["add"], help="Create a webhook subscription" + ) wh_sub.add_argument("name", help="Route name (used in URL: /webhooks/)") - wh_sub.add_argument("--prompt", default="", help="Prompt template with {dot.notation} payload refs") - wh_sub.add_argument("--events", default="", help="Comma-separated event types to accept") + wh_sub.add_argument( + "--prompt", default="", help="Prompt template with {dot.notation} payload refs" + ) + wh_sub.add_argument( + "--events", default="", help="Comma-separated event types to accept" + ) wh_sub.add_argument("--description", default="", help="What this subscription does") - wh_sub.add_argument("--skills", default="", help="Comma-separated skill names to load") - wh_sub.add_argument("--deliver", default="log", help="Delivery target: log, telegram, discord, slack, etc.") - wh_sub.add_argument("--deliver-chat-id", default="", help="Target chat ID for cross-platform delivery") - wh_sub.add_argument("--secret", default="", help="HMAC secret (auto-generated if omitted)") + wh_sub.add_argument( + "--skills", default="", help="Comma-separated skill names to load" + ) + wh_sub.add_argument( + "--deliver", + default="log", + help="Delivery target: log, telegram, discord, slack, etc.", + ) + wh_sub.add_argument( + "--deliver-chat-id", + default="", + help="Target chat ID for cross-platform delivery", + ) + wh_sub.add_argument( + "--secret", default="", help="HMAC secret (auto-generated if omitted)" + ) - webhook_subparsers.add_parser("list", aliases=["ls"], help="List all dynamic subscriptions") + webhook_subparsers.add_parser( + "list", aliases=["ls"], help="List all dynamic subscriptions" + ) - wh_rm = webhook_subparsers.add_parser("remove", aliases=["rm"], help="Remove a subscription") + wh_rm = webhook_subparsers.add_parser( + "remove", aliases=["rm"], help="Remove a subscription" + ) wh_rm.add_argument("name", help="Subscription name to remove") - wh_test = webhook_subparsers.add_parser("test", help="Send a test POST to a webhook route") + wh_test = webhook_subparsers.add_parser( + "test", help="Send a test POST to a webhook route" + ) wh_test.add_argument("name", help="Subscription name to test") - wh_test.add_argument("--payload", default="", help="JSON payload to send (default: test payload)") + wh_test.add_argument( + "--payload", default="", help="JSON payload to send (default: test payload)" + ) webhook_parser.set_defaults(func=cmd_webhook) @@ -5713,12 +6447,10 @@ For more help on a command: doctor_parser = subparsers.add_parser( "doctor", help="Check configuration and dependencies", - description="Diagnose issues with Hermes Agent setup" + description="Diagnose issues with Hermes Agent setup", ) doctor_parser.add_argument( - "--fix", - action="store_true", - help="Attempt to fix issues automatically" + "--fix", action="store_true", help="Attempt to fix issues automatically" ) doctor_parser.set_defaults(func=cmd_doctor) @@ -5729,12 +6461,12 @@ For more help on a command: "dump", help="Dump setup summary for support/debugging", description="Output a compact, plain-text summary of your Hermes setup " - "that can be copy-pasted into Discord/GitHub for support context" + "that can be copy-pasted into Discord/GitHub for support context", ) dump_parser.add_argument( "--show-keys", action="store_true", - help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set" + help="Show redacted API key prefixes (first/last 4 chars) instead of just set/not set", ) dump_parser.set_defaults(func=cmd_dump) @@ -5745,8 +6477,8 @@ For more help on a command: "debug", help="Debug tools — upload logs and system info for support", description="Debug utilities for Hermes Agent. Use 'hermes debug share' to " - "upload a debug report (system info + recent logs) to a paste " - "service and get a shareable URL.", + "upload a debug report (system info + recent logs) to a paste " + "service and get a shareable URL.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="""\ Examples: @@ -5763,15 +6495,20 @@ Examples: help="Upload debug report to a paste service and print a shareable URL", ) share_parser.add_argument( - "--lines", type=int, default=200, + "--lines", + type=int, + default=200, help="Number of log lines to include per log file (default: 200)", ) share_parser.add_argument( - "--expire", type=int, default=7, + "--expire", + type=int, + default=7, help="Paste expiry in days (default: 7)", ) share_parser.add_argument( - "--local", action="store_true", + "--local", + action="store_true", help="Print the report locally instead of uploading", ) delete_parser = debug_sub.add_parser( @@ -5779,7 +6516,9 @@ Examples: help="Delete a paste uploaded by 'hermes debug share'", ) delete_parser.add_argument( - "urls", nargs="*", default=[], + "urls", + nargs="*", + default=[], help="One or more paste URLs to delete (e.g. https://paste.rs/abc123)", ) debug_parser.set_defaults(func=cmd_debug) @@ -5791,21 +6530,22 @@ Examples: "backup", help="Back up Hermes home directory to a zip file", description="Create a zip archive of your entire Hermes configuration, " - "skills, sessions, and data (excludes the hermes-agent codebase). " - "Use --quick for a fast snapshot of just critical state files." + "skills, sessions, and data (excludes the hermes-agent codebase). " + "Use --quick for a fast snapshot of just critical state files.", ) backup_parser.add_argument( - "-o", "--output", - help="Output path for the zip file (default: ~/hermes-backup-.zip)" + "-o", + "--output", + help="Output path for the zip file (default: ~/hermes-backup-.zip)", ) backup_parser.add_argument( - "-q", "--quick", + "-q", + "--quick", action="store_true", - help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)" + help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)", ) backup_parser.add_argument( - "-l", "--label", - help="Label for the snapshot (only used with --quick)" + "-l", "--label", help="Label for the snapshot (only used with --quick)" ) backup_parser.set_defaults(func=cmd_backup) @@ -5816,17 +6556,15 @@ Examples: "import", help="Restore a Hermes backup from a zip file", description="Extract a previously created Hermes backup into your " - "Hermes home directory, restoring configuration, skills, " - "sessions, and data" + "Hermes home directory, restoring configuration, skills, " + "sessions, and data", ) + import_parser.add_argument("zipfile", help="Path to the backup zip file") import_parser.add_argument( - "zipfile", - help="Path to the backup zip file" - ) - import_parser.add_argument( - "--force", "-f", + "--force", + "-f", action="store_true", - help="Overwrite existing files without confirmation" + help="Overwrite existing files without confirmation", ) import_parser.set_defaults(func=cmd_import) @@ -5836,49 +6574,55 @@ Examples: config_parser = subparsers.add_parser( "config", help="View and edit configuration", - description="Manage Hermes Agent configuration" + description="Manage Hermes Agent configuration", ) config_subparsers = config_parser.add_subparsers(dest="config_command") - + # config show (default) config_subparsers.add_parser("show", help="Show current configuration") - + # config edit config_subparsers.add_parser("edit", help="Open config file in editor") - + # config set config_set = config_subparsers.add_parser("set", help="Set a configuration value") - config_set.add_argument("key", nargs="?", help="Configuration key (e.g., model, terminal.backend)") + config_set.add_argument( + "key", nargs="?", help="Configuration key (e.g., model, terminal.backend)" + ) config_set.add_argument("value", nargs="?", help="Value to set") - + # config path config_subparsers.add_parser("path", help="Print config file path") - + # config env-path config_subparsers.add_parser("env-path", help="Print .env file path") - + # config check config_subparsers.add_parser("check", help="Check for missing/outdated config") - + # config migrate config_subparsers.add_parser("migrate", help="Update config with new options") - + config_parser.set_defaults(func=cmd_config) - + # ========================================================================= # pairing command # ========================================================================= pairing_parser = subparsers.add_parser( "pairing", help="Manage DM pairing codes for user authorization", - description="Approve or revoke user access via pairing codes" + description="Approve or revoke user access via pairing codes", ) pairing_sub = pairing_parser.add_subparsers(dest="pairing_action") pairing_sub.add_parser("list", help="Show pending + approved users") - pairing_approve_parser = pairing_sub.add_parser("approve", help="Approve a pairing code") - pairing_approve_parser.add_argument("platform", help="Platform name (telegram, discord, slack, whatsapp)") + pairing_approve_parser = pairing_sub.add_parser( + "approve", help="Approve a pairing code" + ) + pairing_approve_parser.add_argument( + "platform", help="Platform name (telegram, discord, slack, whatsapp)" + ) pairing_approve_parser.add_argument("code", help="Pairing code to approve") pairing_revoke_parser = pairing_sub.add_parser("revoke", help="Revoke user access") @@ -5889,6 +6633,7 @@ Examples: def cmd_pairing(args): from hermes_cli.pairing import pairing_command + pairing_command(args) pairing_parser.set_defaults(func=cmd_pairing) @@ -5899,44 +6644,106 @@ Examples: skills_parser = subparsers.add_parser( "skills", help="Search, install, configure, and manage skills", - description="Search, install, inspect, audit, configure, and manage skills from skills.sh, well-known agent skill endpoints, GitHub, ClawHub, and other registries." + description="Search, install, inspect, audit, configure, and manage skills from skills.sh, well-known agent skill endpoints, GitHub, ClawHub, and other registries.", ) skills_subparsers = skills_parser.add_subparsers(dest="skills_action") - skills_browse = skills_subparsers.add_parser("browse", help="Browse all available skills (paginated)") - skills_browse.add_argument("--page", type=int, default=1, help="Page number (default: 1)") - skills_browse.add_argument("--size", type=int, default=20, help="Results per page (default: 20)") - skills_browse.add_argument("--source", default="all", - choices=["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"], - help="Filter by source (default: all)") + skills_browse = skills_subparsers.add_parser( + "browse", help="Browse all available skills (paginated)" + ) + skills_browse.add_argument( + "--page", type=int, default=1, help="Page number (default: 1)" + ) + skills_browse.add_argument( + "--size", type=int, default=20, help="Results per page (default: 20)" + ) + skills_browse.add_argument( + "--source", + default="all", + choices=[ + "all", + "official", + "skills-sh", + "well-known", + "github", + "clawhub", + "lobehub", + ], + help="Filter by source (default: all)", + ) - skills_search = skills_subparsers.add_parser("search", help="Search skill registries") + skills_search = skills_subparsers.add_parser( + "search", help="Search skill registries" + ) skills_search.add_argument("query", help="Search query") - skills_search.add_argument("--source", default="all", choices=["all", "official", "skills-sh", "well-known", "github", "clawhub", "lobehub"]) + skills_search.add_argument( + "--source", + default="all", + choices=[ + "all", + "official", + "skills-sh", + "well-known", + "github", + "clawhub", + "lobehub", + ], + ) skills_search.add_argument("--limit", type=int, default=10, help="Max results") skills_install = skills_subparsers.add_parser("install", help="Install a skill") - skills_install.add_argument("identifier", help="Skill identifier (e.g. openai/skills/skill-creator)") - skills_install.add_argument("--category", default="", help="Category folder to install into") - skills_install.add_argument("--force", action="store_true", help="Install despite blocked scan verdict") - skills_install.add_argument("--yes", "-y", action="store_true", help="Skip confirmation prompt (needed in TUI mode)") + skills_install.add_argument( + "identifier", help="Skill identifier (e.g. openai/skills/skill-creator)" + ) + skills_install.add_argument( + "--category", default="", help="Category folder to install into" + ) + skills_install.add_argument( + "--force", action="store_true", help="Install despite blocked scan verdict" + ) + skills_install.add_argument( + "--yes", + "-y", + action="store_true", + help="Skip confirmation prompt (needed in TUI mode)", + ) - skills_inspect = skills_subparsers.add_parser("inspect", help="Preview a skill without installing") + skills_inspect = skills_subparsers.add_parser( + "inspect", help="Preview a skill without installing" + ) skills_inspect.add_argument("identifier", help="Skill identifier") skills_list = skills_subparsers.add_parser("list", help="List installed skills") - skills_list.add_argument("--source", default="all", choices=["all", "hub", "builtin", "local"]) + skills_list.add_argument( + "--source", default="all", choices=["all", "hub", "builtin", "local"] + ) - skills_check = skills_subparsers.add_parser("check", help="Check installed hub skills for updates") - skills_check.add_argument("name", nargs="?", help="Specific skill to check (default: all)") + skills_check = skills_subparsers.add_parser( + "check", help="Check installed hub skills for updates" + ) + skills_check.add_argument( + "name", nargs="?", help="Specific skill to check (default: all)" + ) - skills_update = skills_subparsers.add_parser("update", help="Update installed hub skills") - skills_update.add_argument("name", nargs="?", help="Specific skill to update (default: all outdated skills)") + skills_update = skills_subparsers.add_parser( + "update", help="Update installed hub skills" + ) + skills_update.add_argument( + "name", + nargs="?", + help="Specific skill to update (default: all outdated skills)", + ) - skills_audit = skills_subparsers.add_parser("audit", help="Re-scan installed hub skills") - skills_audit.add_argument("name", nargs="?", help="Specific skill to audit (default: all)") + skills_audit = skills_subparsers.add_parser( + "audit", help="Re-scan installed hub skills" + ) + skills_audit.add_argument( + "name", nargs="?", help="Specific skill to audit (default: all)" + ) - skills_uninstall = skills_subparsers.add_parser("uninstall", help="Remove a hub-installed skill") + skills_uninstall = skills_subparsers.add_parser( + "uninstall", help="Remove a hub-installed skill" + ) skills_uninstall.add_argument("name", help="Skill name to remove") skills_reset = skills_subparsers.add_parser( @@ -5948,28 +6755,47 @@ Examples: "replace the current copy with the bundled version." ), ) - skills_reset.add_argument("name", help="Skill name to reset (e.g. google-workspace)") skills_reset.add_argument( - "--restore", action="store_true", + "name", help="Skill name to reset (e.g. google-workspace)" + ) + skills_reset.add_argument( + "--restore", + action="store_true", help="Also delete the current copy and re-copy the bundled version", ) skills_reset.add_argument( - "--yes", "-y", action="store_true", + "--yes", + "-y", + action="store_true", help="Skip confirmation prompt when using --restore", ) - skills_publish = skills_subparsers.add_parser("publish", help="Publish a skill to a registry") + skills_publish = skills_subparsers.add_parser( + "publish", help="Publish a skill to a registry" + ) skills_publish.add_argument("skill_path", help="Path to skill directory") - skills_publish.add_argument("--to", default="github", choices=["github", "clawhub"], help="Target registry") - skills_publish.add_argument("--repo", default="", help="Target GitHub repo (e.g. openai/skills)") + skills_publish.add_argument( + "--to", default="github", choices=["github", "clawhub"], help="Target registry" + ) + skills_publish.add_argument( + "--repo", default="", help="Target GitHub repo (e.g. openai/skills)" + ) - skills_snapshot = skills_subparsers.add_parser("snapshot", help="Export/import skill configurations") + skills_snapshot = skills_subparsers.add_parser( + "snapshot", help="Export/import skill configurations" + ) snapshot_subparsers = skills_snapshot.add_subparsers(dest="snapshot_action") - snap_export = snapshot_subparsers.add_parser("export", help="Export installed skills to a file") + snap_export = snapshot_subparsers.add_parser( + "export", help="Export installed skills to a file" + ) snap_export.add_argument("output", help="Output JSON file path (use - for stdout)") - snap_import = snapshot_subparsers.add_parser("import", help="Import and install skills from a file") + snap_import = snapshot_subparsers.add_parser( + "import", help="Import and install skills from a file" + ) snap_import.add_argument("input", help="Input JSON file path") - snap_import.add_argument("--force", action="store_true", help="Force install despite caution verdict") + snap_import.add_argument( + "--force", action="store_true", help="Force install despite caution verdict" + ) skills_tap = skills_subparsers.add_parser("tap", help="Manage skill sources") tap_subparsers = skills_tap.add_subparsers(dest="tap_action") @@ -5980,16 +6806,21 @@ Examples: tap_rm.add_argument("name", help="Tap name to remove") # config sub-action: interactive enable/disable - skills_subparsers.add_parser("config", help="Interactive skill configuration — enable/disable individual skills") + skills_subparsers.add_parser( + "config", + help="Interactive skill configuration — enable/disable individual skills", + ) def cmd_skills(args): # Route 'config' action to skills_config module - if getattr(args, 'skills_action', None) == 'config': + if getattr(args, "skills_action", None) == "config": _require_tty("skills config") from hermes_cli.skills_config import skills_command as skills_config_command + skills_config_command(args) else: from hermes_cli.skills_hub import skills_command + skills_command(args) skills_parser.set_defaults(func=cmd_skills) @@ -6012,7 +6843,9 @@ Examples: help="Git URL or owner/repo shorthand (e.g. anpicasso/hermes-plugin-chrome-profiles)", ) plugins_install.add_argument( - "--force", "-f", action="store_true", + "--force", + "-f", + action="store_true", help="Remove existing plugin and reinstall", ) @@ -6040,6 +6873,7 @@ Examples: def cmd_plugins(args): from hermes_cli.plugins_cmd import plugins_command + plugins_command(args) plugins_parser.set_defaults(func=cmd_plugins) @@ -6051,6 +6885,7 @@ Examples: # ========================================================================= try: from plugins.memory import discover_plugin_cli_commands + for cmd_info in discover_plugin_cli_commands(): plugin_parser = subparsers.add_parser( cmd_info["name"], @@ -6061,6 +6896,7 @@ Examples: cmd_info["setup_fn"](plugin_parser) except Exception as _exc: import logging as _log + _log.getLogger(__name__).debug("Plugin CLI discovery failed: %s", _exc) # ========================================================================= @@ -6078,7 +6914,9 @@ Examples: ), ) memory_sub = memory_parser.add_subparsers(dest="memory_command") - memory_sub.add_parser("setup", help="Interactive provider selection and configuration") + memory_sub.add_parser( + "setup", help="Interactive provider selection and configuration" + ) memory_sub.add_parser("status", help="Show current memory provider config") memory_sub.add_parser("off", help="Disable external provider (built-in only)") _reset_parser = memory_sub.add_parser( @@ -6086,11 +6924,15 @@ Examples: help="Erase all built-in memory (MEMORY.md and USER.md)", ) _reset_parser.add_argument( - "--yes", "-y", action="store_true", + "--yes", + "-y", + action="store_true", help="Skip confirmation prompt", ) _reset_parser.add_argument( - "--target", choices=["all", "memory", "user"], default="all", + "--target", + choices=["all", "memory", "user"], + default="all", help="Which store to reset: 'all' (default), 'memory', or 'user'", ) @@ -6098,6 +6940,7 @@ Examples: sub = getattr(args, "memory_command", None) if sub == "off": from hermes_cli.config import load_config, save_config + config = load_config() if not isinstance(config.get("memory"), dict): config["memory"] = {} @@ -6107,6 +6950,7 @@ Examples: print(" Saved to config.yaml\n") elif sub == "reset": from hermes_constants import get_hermes_home, display_hermes_home + mem_dir = get_hermes_home() / "memories" target = getattr(args, "target", "all") files_to_reset = [] @@ -6116,9 +6960,13 @@ Examples: files_to_reset.append(("USER.md", "user profile")) # Check what exists - existing = [(f, desc) for f, desc in files_to_reset if (mem_dir / f).exists()] + existing = [ + (f, desc) for f, desc in files_to_reset if (mem_dir / f).exists() + ] if not existing: - print(f"\n Nothing to reset — no memory files found in {display_hermes_home()}/memories/\n") + print( + f"\n Nothing to reset — no memory files found in {display_hermes_home()}/memories/\n" + ) return print(f"\n This will permanently erase the following memory files:") @@ -6141,10 +6989,13 @@ Examples: (mem_dir / f).unlink() print(f" ✓ Deleted {f} ({desc})") - print(f"\n Memory reset complete. New sessions will start with a blank slate.") + print( + f"\n Memory reset complete. New sessions will start with a blank slate." + ) print(f" Files were in: {display_hermes_home()}/memories/\n") else: from hermes_cli.memory_setup import memory_command + memory_command(args) memory_parser.set_defaults(func=cmd_memory) @@ -6165,7 +7016,7 @@ Examples: tools_parser.add_argument( "--summary", action="store_true", - help="Print a summary of enabled tools per platform and exit" + help="Print a summary of enabled tools per platform and exit", ) tools_sub = tools_parser.add_subparsers(dest="tools_action") @@ -6175,7 +7026,8 @@ Examples: help="Show all tools and their enabled/disabled status", ) tools_list_p.add_argument( - "--platform", default="cli", + "--platform", + default="cli", help="Platform to show (default: cli)", ) @@ -6185,11 +7037,14 @@ Examples: help="Disable toolsets or MCP tools", ) tools_disable_p.add_argument( - "names", nargs="+", metavar="NAME", + "names", + nargs="+", + metavar="NAME", help="Toolset name (e.g. web) or MCP tool in server:tool form", ) tools_disable_p.add_argument( - "--platform", default="cli", + "--platform", + default="cli", help="Platform to apply to (default: cli)", ) @@ -6199,11 +7054,14 @@ Examples: help="Enable toolsets or MCP tools", ) tools_enable_p.add_argument( - "names", nargs="+", metavar="NAME", + "names", + nargs="+", + metavar="NAME", help="Toolset name or MCP tool in server:tool form", ) tools_enable_p.add_argument( - "--platform", default="cli", + "--platform", + default="cli", help="Platform to apply to (default: cli)", ) @@ -6211,10 +7069,12 @@ Examples: action = getattr(args, "tools_action", None) if action in ("list", "disable", "enable"): from hermes_cli.tools_config import tools_disable_enable_command + tools_disable_enable_command(args) else: _require_tty("tools") from hermes_cli.tools_config import tools_command + tools_command(args) tools_parser.set_defaults(func=cmd_tools) @@ -6238,18 +7098,29 @@ Examples: help="Run Hermes as an MCP server (expose conversations to other agents)", ) mcp_serve_p.add_argument( - "-v", "--verbose", action="store_true", + "-v", + "--verbose", + action="store_true", help="Enable verbose logging on stderr", ) - mcp_add_p = mcp_sub.add_parser("add", help="Add an MCP server (discovery-first install)") + mcp_add_p = mcp_sub.add_parser( + "add", help="Add an MCP server (discovery-first install)" + ) mcp_add_p.add_argument("name", help="Server name (used as config key)") mcp_add_p.add_argument("--url", help="HTTP/SSE endpoint URL") mcp_add_p.add_argument("--command", help="Stdio command (e.g. npx)") - mcp_add_p.add_argument("--args", nargs="*", default=[], help="Arguments for stdio command") + mcp_add_p.add_argument( + "--args", nargs="*", default=[], help="Arguments for stdio command" + ) mcp_add_p.add_argument("--auth", choices=["oauth", "header"], help="Auth method") mcp_add_p.add_argument("--preset", help="Known MCP preset name") - mcp_add_p.add_argument("--env", nargs="*", default=[], help="Environment variables for stdio servers (KEY=VALUE)") + mcp_add_p.add_argument( + "--env", + nargs="*", + default=[], + help="Environment variables for stdio servers (KEY=VALUE)", + ) mcp_rm_p = mcp_sub.add_parser("remove", aliases=["rm"], help="Remove an MCP server") mcp_rm_p.add_argument("name", help="Server name to remove") @@ -6259,7 +7130,9 @@ Examples: mcp_test_p = mcp_sub.add_parser("test", help="Test MCP server connection") mcp_test_p.add_argument("name", help="Server name to test") - mcp_cfg_p = mcp_sub.add_parser("configure", aliases=["config"], help="Toggle tool selection") + mcp_cfg_p = mcp_sub.add_parser( + "configure", aliases=["config"], help="Toggle tool selection" + ) mcp_cfg_p.add_argument("name", help="Server name to configure") mcp_login_p = mcp_sub.add_parser( @@ -6270,6 +7143,7 @@ Examples: def cmd_mcp(args): from hermes_cli.mcp_config import mcp_command + mcp_command(args) mcp_parser.set_defaults(func=cmd_mcp) @@ -6280,31 +7154,52 @@ Examples: sessions_parser = subparsers.add_parser( "sessions", help="Manage session history (list, rename, export, prune, delete)", - description="View and manage the SQLite session store" + description="View and manage the SQLite session store", ) sessions_subparsers = sessions_parser.add_subparsers(dest="sessions_action") sessions_list = sessions_subparsers.add_parser("list", help="List recent sessions") - sessions_list.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)") - sessions_list.add_argument("--limit", type=int, default=20, help="Max sessions to show") + sessions_list.add_argument( + "--source", help="Filter by source (cli, telegram, discord, etc.)" + ) + sessions_list.add_argument( + "--limit", type=int, default=20, help="Max sessions to show" + ) - sessions_export = sessions_subparsers.add_parser("export", help="Export sessions to a JSONL file") - sessions_export.add_argument("output", help="Output JSONL file path (use - for stdout)") + sessions_export = sessions_subparsers.add_parser( + "export", help="Export sessions to a JSONL file" + ) + sessions_export.add_argument( + "output", help="Output JSONL file path (use - for stdout)" + ) sessions_export.add_argument("--source", help="Filter by source") sessions_export.add_argument("--session-id", help="Export a specific session") - sessions_delete = sessions_subparsers.add_parser("delete", help="Delete a specific session") + sessions_delete = sessions_subparsers.add_parser( + "delete", help="Delete a specific session" + ) sessions_delete.add_argument("session_id", help="Session ID to delete") - sessions_delete.add_argument("--yes", "-y", action="store_true", help="Skip confirmation") + sessions_delete.add_argument( + "--yes", "-y", action="store_true", help="Skip confirmation" + ) sessions_prune = sessions_subparsers.add_parser("prune", help="Delete old sessions") - sessions_prune.add_argument("--older-than", type=int, default=90, help="Delete sessions older than N days (default: 90)") + sessions_prune.add_argument( + "--older-than", + type=int, + default=90, + help="Delete sessions older than N days (default: 90)", + ) sessions_prune.add_argument("--source", help="Only prune sessions from this source") - sessions_prune.add_argument("--yes", "-y", action="store_true", help="Skip confirmation") + sessions_prune.add_argument( + "--yes", "-y", action="store_true", help="Skip confirmation" + ) sessions_subparsers.add_parser("stats", help="Show session store statistics") - sessions_rename = sessions_subparsers.add_parser("rename", help="Set or change a session's title") + sessions_rename = sessions_subparsers.add_parser( + "rename", help="Set or change a session's title" + ) sessions_rename.add_argument("session_id", help="Session ID to rename") sessions_rename.add_argument("title", nargs="+", help="New title for the session") @@ -6312,8 +7207,12 @@ Examples: "browse", help="Interactive session picker — browse, search, and resume sessions", ) - sessions_browse.add_argument("--source", help="Filter by source (cli, telegram, discord, etc.)") - sessions_browse.add_argument("--limit", type=int, default=50, help="Max sessions to load (default: 50)") + sessions_browse.add_argument( + "--source", help="Filter by source (cli, telegram, discord, etc.)" + ) + sessions_browse.add_argument( + "--limit", type=int, default=50, help="Max sessions to load (default: 50)" + ) def _confirm_prompt(prompt: str) -> bool: """Prompt for y/N confirmation, safe against non-TTY environments.""" @@ -6324,8 +7223,10 @@ Examples: def cmd_sessions(args): import json as _json + try: from hermes_state import SessionDB + db = SessionDB() except Exception as e: print(f"Error: Could not open session database: {e}") @@ -6338,7 +7239,9 @@ Examples: _exclude = None if _source else ["tool"] if action == "list": - sessions = db.list_sessions_rich(source=args.source, exclude_sources=_exclude, limit=args.limit) + sessions = db.list_sessions_rich( + source=args.source, exclude_sources=_exclude, limit=args.limit + ) if not sessions: print("No sessions found.") return @@ -6351,7 +7254,11 @@ Examples: print("─" * 95) for s in sessions: last_active = _relative_time(s.get("last_active")) - preview = s.get("preview", "")[:38] if has_titles else s.get("preview", "")[:48] + preview = ( + s.get("preview", "")[:38] + if has_titles + else s.get("preview", "")[:48] + ) if has_titles: title = (s.get("title") or "—")[:30] sid = s["id"] @@ -6373,6 +7280,7 @@ Examples: line = _json.dumps(data, ensure_ascii=False) + "\n" if args.output == "-": import sys + sys.stdout.write(line) else: with open(args.output, "w", encoding="utf-8") as f: @@ -6382,6 +7290,7 @@ Examples: sessions = db.export_all(source=args.source) if args.output == "-": import sys + for s in sessions: sys.stdout.write(_json.dumps(s, ensure_ascii=False) + "\n") else: @@ -6396,7 +7305,9 @@ Examples: print(f"Session '{args.session_id}' not found.") return if not args.yes: - if not _confirm_prompt(f"Delete session '{resolved_session_id}' and all its messages? [y/N] "): + if not _confirm_prompt( + f"Delete session '{resolved_session_id}' and all its messages? [y/N] " + ): print("Cancelled.") return if db.delete_session(resolved_session_id): @@ -6408,7 +7319,9 @@ Examples: days = args.older_than source_msg = f" from '{args.source}'" if args.source else "" if not args.yes: - if not _confirm_prompt(f"Delete all ended sessions older than {days} days{source_msg}? [y/N] "): + if not _confirm_prompt( + f"Delete all ended sessions older than {days} days{source_msg}? [y/N] " + ): print("Cancelled.") return count = db.prune_sessions(older_than_days=days, source=args.source) @@ -6432,7 +7345,9 @@ Examples: limit = getattr(args, "limit", 50) or 50 source = getattr(args, "source", None) _browse_exclude = None if source else ["tool"] - sessions = db.list_sessions_rich(source=source, exclude_sources=_browse_exclude, limit=limit) + sessions = db.list_sessions_rich( + source=source, exclude_sources=_browse_exclude, limit=limit + ) db.close() if not sessions: print("No sessions found.") @@ -6446,6 +7361,7 @@ Examples: # Launch hermes --resume by replacing the current process print(f"Resuming session: {selected_id}") import shutil + hermes_bin = shutil.which("hermes") if hermes_bin: os.execvp(hermes_bin, ["hermes", "--resume", selected_id]) @@ -6484,10 +7400,14 @@ Examples: insights_parser = subparsers.add_parser( "insights", help="Show usage insights and analytics", - description="Analyze session history to show token usage, costs, tool patterns, and activity trends" + description="Analyze session history to show token usage, costs, tool patterns, and activity trends", + ) + insights_parser.add_argument( + "--days", type=int, default=30, help="Number of days to analyze (default: 30)" + ) + insights_parser.add_argument( + "--source", help="Filter by platform (cli, telegram, discord, etc.)" ) - insights_parser.add_argument("--days", type=int, default=30, help="Number of days to analyze (default: 30)") - insights_parser.add_argument("--source", help="Filter by platform (cli, telegram, discord, etc.)") def cmd_insights(args): try: @@ -6510,7 +7430,7 @@ Examples: claw_parser = subparsers.add_parser( "claw", help="OpenClaw migration tools", - description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes" + description="Migrate settings, memories, skills, and API keys from OpenClaw to Hermes", ) claw_subparsers = claw_parser.add_subparsers(dest="claw_action") @@ -6519,47 +7439,43 @@ Examples: "migrate", help="Migrate from OpenClaw to Hermes", description="Import settings, memories, skills, and API keys from an OpenClaw installation. " - "Always shows a preview before making changes." + "Always shows a preview before making changes.", ) claw_migrate.add_argument( - "--source", - help="Path to OpenClaw directory (default: ~/.openclaw)" + "--source", help="Path to OpenClaw directory (default: ~/.openclaw)" ) claw_migrate.add_argument( "--dry-run", action="store_true", - help="Preview only — stop after showing what would be migrated" + help="Preview only — stop after showing what would be migrated", ) claw_migrate.add_argument( "--preset", choices=["user-data", "full"], default="full", - help="Migration preset (default: full). 'user-data' excludes secrets" + help="Migration preset (default: full). 'user-data' excludes secrets", ) claw_migrate.add_argument( "--overwrite", action="store_true", - help="Overwrite existing files (default: skip conflicts)" + help="Overwrite existing files (default: skip conflicts)", ) claw_migrate.add_argument( "--migrate-secrets", action="store_true", - help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.)" + help="Include allowlisted secrets (TELEGRAM_BOT_TOKEN, API keys, etc.)", ) claw_migrate.add_argument( - "--workspace-target", - help="Absolute path to copy workspace instructions into" + "--workspace-target", help="Absolute path to copy workspace instructions into" ) claw_migrate.add_argument( "--skill-conflict", choices=["skip", "overwrite", "rename"], default="skip", - help="How to handle skill name conflicts (default: skip)" + help="How to handle skill name conflicts (default: skip)", ) claw_migrate.add_argument( - "--yes", "-y", - action="store_true", - help="Skip confirmation prompts" + "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) # claw cleanup @@ -6567,25 +7483,23 @@ Examples: "cleanup", aliases=["clean"], help="Archive leftover OpenClaw directories after migration", - description="Scan for and archive leftover OpenClaw directories to prevent state fragmentation" + description="Scan for and archive leftover OpenClaw directories to prevent state fragmentation", ) claw_cleanup.add_argument( - "--source", - help="Path to a specific OpenClaw directory to clean up" + "--source", help="Path to a specific OpenClaw directory to clean up" ) claw_cleanup.add_argument( "--dry-run", action="store_true", - help="Preview what would be archived without making changes" + help="Preview what would be archived without making changes", ) claw_cleanup.add_argument( - "--yes", "-y", - action="store_true", - help="Skip confirmation prompts" + "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) def cmd_claw(args): from hermes_cli.claw import claw_command + claw_command(args) claw_parser.set_defaults(func=cmd_claw) @@ -6593,43 +7507,40 @@ Examples: # ========================================================================= # version command # ========================================================================= - version_parser = subparsers.add_parser( - "version", - help="Show version information" - ) + version_parser = subparsers.add_parser("version", help="Show version information") version_parser.set_defaults(func=cmd_version) - + # ========================================================================= # update command # ========================================================================= update_parser = subparsers.add_parser( "update", help="Update Hermes Agent to the latest version", - description="Pull the latest changes from git and reinstall dependencies" + description="Pull the latest changes from git and reinstall dependencies", ) update_parser.add_argument( - "--gateway", action="store_true", default=False, - help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)" + "--gateway", + action="store_true", + default=False, + help="Gateway mode: use file-based IPC for prompts instead of stdin (used internally by /update)", ) update_parser.set_defaults(func=cmd_update) - + # ========================================================================= # uninstall command # ========================================================================= uninstall_parser = subparsers.add_parser( "uninstall", help="Uninstall Hermes Agent", - description="Remove Hermes Agent from your system. Can keep configs/data for reinstall." + description="Remove Hermes Agent from your system. Can keep configs/data for reinstall.", ) uninstall_parser.add_argument( "--full", action="store_true", - help="Full uninstall - remove everything including configs and data" + help="Full uninstall - remove everything including configs and data", ) uninstall_parser.add_argument( - "--yes", "-y", - action="store_true", - help="Skip confirmation prompts" + "--yes", "-y", action="store_true", help="Skip confirmation prompts" ) uninstall_parser.set_defaults(func=cmd_uninstall) @@ -6646,6 +7557,7 @@ Examples: """Launch Hermes Agent as an ACP server.""" try: from acp_adapter.entry import main as acp_main + acp_main() except ImportError: print("ACP dependencies not installed.") @@ -6664,48 +7576,81 @@ Examples: profile_subparsers = profile_parser.add_subparsers(dest="profile_action") profile_subparsers.add_parser("list", help="List all profiles") - profile_use = profile_subparsers.add_parser("use", help="Set sticky default profile") + profile_use = profile_subparsers.add_parser( + "use", help="Set sticky default profile" + ) profile_use.add_argument("profile_name", help="Profile name (or 'default')") - profile_create = profile_subparsers.add_parser("create", help="Create a new profile") - profile_create.add_argument("profile_name", help="Profile name (lowercase, alphanumeric)") - profile_create.add_argument("--clone", action="store_true", - help="Copy config.yaml, .env, SOUL.md from active profile") - profile_create.add_argument("--clone-all", action="store_true", - help="Full copy of active profile (all state)") - profile_create.add_argument("--clone-from", metavar="SOURCE", - help="Source profile to clone from (default: active)") - profile_create.add_argument("--no-alias", action="store_true", - help="Skip wrapper script creation") + profile_create = profile_subparsers.add_parser( + "create", help="Create a new profile" + ) + profile_create.add_argument( + "profile_name", help="Profile name (lowercase, alphanumeric)" + ) + profile_create.add_argument( + "--clone", + action="store_true", + help="Copy config.yaml, .env, SOUL.md from active profile", + ) + profile_create.add_argument( + "--clone-all", + action="store_true", + help="Full copy of active profile (all state)", + ) + profile_create.add_argument( + "--clone-from", + metavar="SOURCE", + help="Source profile to clone from (default: active)", + ) + profile_create.add_argument( + "--no-alias", action="store_true", help="Skip wrapper script creation" + ) profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") profile_delete.add_argument("profile_name", help="Profile to delete") - profile_delete.add_argument("-y", "--yes", action="store_true", - help="Skip confirmation prompt") + profile_delete.add_argument( + "-y", "--yes", action="store_true", help="Skip confirmation prompt" + ) profile_show = profile_subparsers.add_parser("show", help="Show profile details") profile_show.add_argument("profile_name", help="Profile to show") - profile_alias = profile_subparsers.add_parser("alias", help="Manage wrapper scripts") + profile_alias = profile_subparsers.add_parser( + "alias", help="Manage wrapper scripts" + ) profile_alias.add_argument("profile_name", help="Profile name") - profile_alias.add_argument("--remove", action="store_true", - help="Remove the wrapper script") - profile_alias.add_argument("--name", dest="alias_name", metavar="NAME", - help="Custom alias name (default: profile name)") + profile_alias.add_argument( + "--remove", action="store_true", help="Remove the wrapper script" + ) + profile_alias.add_argument( + "--name", + dest="alias_name", + metavar="NAME", + help="Custom alias name (default: profile name)", + ) profile_rename = profile_subparsers.add_parser("rename", help="Rename a profile") profile_rename.add_argument("old_name", help="Current profile name") profile_rename.add_argument("new_name", help="New profile name") - profile_export = profile_subparsers.add_parser("export", help="Export a profile to archive") + profile_export = profile_subparsers.add_parser( + "export", help="Export a profile to archive" + ) profile_export.add_argument("profile_name", help="Profile to export") - profile_export.add_argument("-o", "--output", default=None, - help="Output file (default: .tar.gz)") + profile_export.add_argument( + "-o", "--output", default=None, help="Output file (default: .tar.gz)" + ) - profile_import = profile_subparsers.add_parser("import", help="Import a profile from archive") + profile_import = profile_subparsers.add_parser( + "import", help="Import a profile from archive" + ) profile_import.add_argument("archive", help="Path to .tar.gz archive") - profile_import.add_argument("--name", dest="import_name", metavar="NAME", - help="Profile name (default: inferred from archive)") + profile_import.add_argument( + "--name", + dest="import_name", + metavar="NAME", + help="Profile name (default: inferred from archive)", + ) profile_parser.set_defaults(func=cmd_profile) @@ -6717,7 +7662,10 @@ Examples: help="Print shell completion script (bash, zsh, or fish)", ) completion_parser.add_argument( - "shell", nargs="?", default="bash", choices=["bash", "zsh", "fish"], + "shell", + nargs="?", + default="bash", + choices=["bash", "zsh", "fish"], help="Shell type (default: bash)", ) completion_parser.set_defaults(func=lambda args: cmd_completion(args, parser)) @@ -6730,11 +7678,18 @@ Examples: help="Start the web UI dashboard", description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions", ) - dashboard_parser.add_argument("--port", type=int, default=9119, help="Port (default 9119)") - dashboard_parser.add_argument("--host", default="127.0.0.1", help="Host (default 127.0.0.1)") - dashboard_parser.add_argument("--no-open", action="store_true", help="Don't open browser automatically") dashboard_parser.add_argument( - "--insecure", action="store_true", + "--port", type=int, default=9119, help="Port (default 9119)" + ) + dashboard_parser.add_argument( + "--host", default="127.0.0.1", help="Host (default 127.0.0.1)" + ) + dashboard_parser.add_argument( + "--no-open", action="store_true", help="Don't open browser automatically" + ) + dashboard_parser.add_argument( + "--insecure", + action="store_true", help="Allow binding to non-localhost (DANGEROUS: exposes API keys on the network)", ) dashboard_parser.set_defaults(func=cmd_dashboard) @@ -6762,31 +7717,42 @@ Examples: """, ) logs_parser.add_argument( - "log_name", nargs="?", default="agent", + "log_name", + nargs="?", + default="agent", help="Log to view: agent (default), errors, gateway, or 'list' to show available files", ) logs_parser.add_argument( - "-n", "--lines", type=int, default=50, + "-n", + "--lines", + type=int, + default=50, help="Number of lines to show (default: 50)", ) logs_parser.add_argument( - "-f", "--follow", action="store_true", + "-f", + "--follow", + action="store_true", help="Follow the log in real time (like tail -f)", ) logs_parser.add_argument( - "--level", metavar="LEVEL", + "--level", + metavar="LEVEL", help="Minimum log level to show (DEBUG, INFO, WARNING, ERROR)", ) logs_parser.add_argument( - "--session", metavar="ID", + "--session", + metavar="ID", help="Filter lines containing this session ID substring", ) logs_parser.add_argument( - "--since", metavar="TIME", + "--since", + metavar="TIME", help="Show lines since TIME ago (e.g. 1h, 30m, 2d)", ) logs_parser.add_argument( - "--component", metavar="NAME", + "--component", + metavar="NAME", help="Filter by component: gateway, agent, tools, cli, cron", ) logs_parser.set_defaults(func=cmd_logs) @@ -6803,6 +7769,7 @@ Examples: # --help, unrecognised flags, and every subcommand are forwarded # transparently instead of being intercepted by argparse on the host. from hermes_cli.config import get_container_exec_info + container_info = get_container_exec_info() if container_info: _exec_in_container(container_info, sys.argv[1:]) @@ -6823,8 +7790,13 @@ Examples: # fails (e.g. 'hermes -c model' where 'model' is consumed as the # session name for --continue), fall back to the default behaviour. import io as _io - _known_cmds = set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set() - _has_cmd_token = any(t in _known_cmds for t in _processed_argv if not t.startswith("-")) + + _known_cmds = ( + set(subparsers.choices.keys()) if hasattr(subparsers, "choices") else set() + ) + _has_cmd_token = any( + t in _known_cmds for t in _processed_argv if not t.startswith("-") + ) if _has_cmd_token: subparsers.required = True @@ -6852,29 +7824,42 @@ Examples: if args.version: cmd_version(args) return - + # Handle top-level --resume / --continue as shortcut to chat if (args.resume or args.continue_last) and args.command is None: args.command = "chat" - for attr, default in [("query", None), ("model", None), ("provider", None), - ("toolsets", None), ("verbose", False), ("worktree", False)]: + for attr, default in [ + ("query", None), + ("model", None), + ("provider", None), + ("toolsets", None), + ("verbose", False), + ("worktree", False), + ]: if not hasattr(args, attr): setattr(args, attr, default) cmd_chat(args) return - + # Default to chat if no command specified if args.command is None: - for attr, default in [("query", None), ("model", None), ("provider", None), - ("toolsets", None), ("verbose", False), ("resume", None), - ("continue_last", None), ("worktree", False)]: + for attr, default in [ + ("query", None), + ("model", None), + ("provider", None), + ("toolsets", None), + ("verbose", False), + ("resume", None), + ("continue_last", None), + ("worktree", False), + ]: if not hasattr(args, attr): setattr(args, attr, default) cmd_chat(args) return - + # Execute the command - if hasattr(args, 'func'): + if hasattr(args, "func"): args.func(args) else: parser.print_help() From 5b386ced7117c32ace47322925a9123aef68ba6b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 10:37:48 -0500 Subject: [PATCH 148/157] fix(tui): approval flow + input ergonomics + selection perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tui_gateway: route approvals through gateway callback (HERMES_GATEWAY_SESSION/ HERMES_EXEC_ASK) so dangerous commands emit approval.request instead of silently falling through the CLI input() path and auto-denying - approval UX: dedicated PromptZone between transcript and composer, safer defaults (sel=0, numeric quick-picks, no Esc=deny), activity trail line, outcome footer under the cost row - text input: Ctrl+A select-all, real forward Delete, Ctrl+W always consumed (fixes Ctrl+Backspace at cursor 0 inserting literal w) - hermes-ink selection: swap synchronous onRender() for throttled scheduleRender() on drag, and only notify React subscribers on presence change — no more per-cell paint/subscribe spam - useConfigSync: silence config.get polling failures instead of surfacing 'error: timeout: config.get' in the transcript --- tests/test_tui_gateway_server.py | 12 ++ tui_gateway/server.py | 11 +- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 13 +- ui-tui/src/app/createGatewayEventHandler.ts | 7 +- ui-tui/src/app/interfaces.ts | 2 + ui-tui/src/app/turnController.ts | 8 +- ui-tui/src/app/turnStore.ts | 2 + ui-tui/src/app/useConfigSync.ts | 35 +++-- ui-tui/src/app/useInputHandlers.ts | 17 ++- ui-tui/src/app/useMainApp.ts | 7 +- ui-tui/src/components/appLayout.tsx | 17 ++- ui-tui/src/components/appOverlays.tsx | 113 ++++++++------- ui-tui/src/components/prompts.tsx | 38 ++--- ui-tui/src/components/textInput.tsx | 146 +++++++++++++++++--- ui-tui/src/components/thinking.tsx | 20 ++- 15 files changed, 319 insertions(+), 129 deletions(-) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 9c6305dedc..e1170b1067 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -117,6 +117,18 @@ def test_config_set_yolo_toggles_session_scope(): server._sessions.clear() +def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): + monkeypatch.delenv("HERMES_EXEC_ASK", raising=False) + monkeypatch.delenv("HERMES_GATEWAY_SESSION", raising=False) + monkeypatch.delenv("HERMES_INTERACTIVE", raising=False) + + server._enable_gateway_prompts() + + assert server.os.environ["HERMES_GATEWAY_SESSION"] == "1" + assert server.os.environ["HERMES_EXEC_ASK"] == "1" + assert server.os.environ["HERMES_INTERACTIVE"] == "1" + + def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) agent = types.SimpleNamespace(reasoning_config=None) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2fd4f49e8b..ff5e2cbf9c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -284,6 +284,13 @@ def _clear_session_context(tokens: list) -> None: pass +def _enable_gateway_prompts() -> None: + """Route approvals through gateway callbacks instead of CLI input().""" + os.environ["HERMES_GATEWAY_SESSION"] = "1" + os.environ["HERMES_EXEC_ASK"] = "1" + os.environ["HERMES_INTERACTIVE"] = "1" + + # ── Blocking prompt factory ────────────────────────────────────────── def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: @@ -1043,7 +1050,7 @@ def _(rid, params: dict) -> dict: sid = uuid.uuid4().hex[:8] key = _new_session_key() cols = int(params.get("cols", 80)) - os.environ["HERMES_INTERACTIVE"] = "1" + _enable_gateway_prompts() ready = threading.Event() @@ -1149,7 +1156,7 @@ def _(rid, params: dict) -> dict: else: return _err(rid, 4007, "session not found") sid = uuid.uuid4().hex[:8] - os.environ["HERMES_INTERACTIVE"] = "1" + _enable_gateway_prompts() try: db.reopen_session(target) history = db.get_messages_as_conversation(target) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 7daa876ac3..c9f90b6f98 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -202,6 +202,7 @@ export default class Ink { // Fired alongside the terminal repaint whenever the selection mutates // so UI (e.g. footer hints) can react to selection appearing/clearing. private readonly selectionListeners = new Set<() => void>() + private selectionWasActive = false // DOM nodes currently under the pointer (mode-1003 motion). Held here // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // against this set and mutates it in place. @@ -1506,10 +1507,16 @@ export default class Ink { return () => this.selectionListeners.delete(cb) } private notifySelectionChange(): void { - this.onRender() + this.scheduleRender() - for (const cb of this.selectionListeners) { - cb() + const active = hasSelection(this.selection) + + if (active !== this.selectionWasActive) { + this.selectionWasActive = active + + for (const cb of this.selectionListeners) { + cb() + } } } diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 46d34a70a4..62b40f6193 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -301,12 +301,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: setStatus('waiting for input…') return + case 'approval.request': { + const description = String(ev.payload.description ?? 'dangerous command') - case 'approval.request': - patchOverlayState({ approval: { command: ev.payload.command, description: ev.payload.description } }) + patchOverlayState({ approval: { command: String(ev.payload.command ?? ''), description } }) + turnController.pushActivity(`approval needed · ${description}`, 'warn') setStatus('approval needed') return + } case 'sudo.request': patchOverlayState({ sudo: { requestId: ev.payload.request_id } }) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index b34ee54bea..d14536624a 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -26,6 +26,7 @@ export interface StateSetter { } export interface SelectionApi { + clearSelection: () => void copySelection: () => string } @@ -275,6 +276,7 @@ export interface AppLayoutComposerProps { export interface AppLayoutProgressProps { activity: ActivityItem[] + outcome: string reasoning: string reasoningActive: boolean reasoningStreaming: boolean diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index df2814277f..be25dcbbe8 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -91,7 +91,7 @@ class TurnController { this.idle() this.clearReasoning() this.turnTools = [] - patchTurnState({ activity: [] }) + patchTurnState({ activity: [], outcome: '' }) patchUiState({ status: 'interrupted' }) this.clearStatusTimer() @@ -176,7 +176,7 @@ class TurnController { this.turnTools = [] this.persistedToolLabels.clear() this.bufRef = '' - patchTurnState({ activity: [] }) + patchTurnState({ activity: [], outcome: '' }) return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } } @@ -271,7 +271,7 @@ class TurnController { this.turnTools = [] this.toolTokenAcc = 0 this.persistedToolLabels.clear() - patchTurnState({ activity: [] }) + patchTurnState({ activity: [], outcome: '' }) } fullReset() { @@ -312,7 +312,7 @@ class TurnController { this.toolTokenAcc = 0 this.persistedToolLabels.clear() patchUiState({ busy: true }) - patchTurnState({ activity: [], subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) + patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) } upsertSubagent(p: SubagentEventPayload, patch: (current: SubagentProgress) => Partial) { diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index d84633c947..9b7e04db78 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -4,6 +4,7 @@ import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' const buildTurnState = (): TurnState => ({ activity: [], + outcome: '', reasoning: '', reasoningActive: false, reasoningStreaming: false, @@ -26,6 +27,7 @@ export const resetTurnState = () => $turnState.set(buildTurnState()) export interface TurnState { activity: ActivityItem[] + outcome: string reasoning: string reasoningActive: boolean reasoningStreaming: boolean diff --git a/ui-tui/src/app/useConfigSync.ts b/ui-tui/src/app/useConfigSync.ts index 3b40e72464..fe3cec5737 100644 --- a/ui-tui/src/app/useConfigSync.ts +++ b/ui-tui/src/app/useConfigSync.ts @@ -1,19 +1,32 @@ import { useEffect, useRef } from 'react' import { resolveDetailsMode } from '../domain/details.js' +import type { GatewayClient } from '../gatewayClient.js' import type { ConfigFullResponse, ConfigMtimeResponse, ReloadMcpResponse, VoiceToggleResponse } from '../gatewayTypes.js' +import { asRpcResult } from '../lib/rpc.js' -import type { GatewayRpc } from './interfaces.js' import { turnController } from './turnController.js' import { patchUiState } from './uiStore.js' const MTIME_POLL_MS = 5000 +const quietRpc = async = Record>( + gw: GatewayClient, + method: string, + params: Record = {} +): Promise => { + try { + return asRpcResult(await gw.request(method, params)) + } catch { + return null + } +} + const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => void) => { const d = cfg?.config?.display ?? {} @@ -25,7 +38,7 @@ const applyDisplay = (cfg: ConfigFullResponse | null, setBell: (v: boolean) => v }) } -export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) { +export function useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid }: UseConfigSyncOptions) { const mtimeRef = useRef(0) useEffect(() => { @@ -33,12 +46,12 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: return } - rpc('voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled)) - rpc('config.get', { key: 'mtime' }).then(r => { + quietRpc(gw, 'voice.toggle', { action: 'status' }).then(r => setVoiceEnabled(!!r?.enabled)) + quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { mtimeRef.current = Number(r?.mtime ?? 0) }) - rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) - }, [rpc, setBellOnComplete, setVoiceEnabled, sid]) + quietRpc(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + }, [gw, setBellOnComplete, setVoiceEnabled, sid]) useEffect(() => { if (!sid) { @@ -46,7 +59,7 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: } const id = setInterval(() => { - rpc('config.get', { key: 'mtime' }).then(r => { + quietRpc(gw, 'config.get', { key: 'mtime' }).then(r => { const next = Number(r?.mtime ?? 0) if (!mtimeRef.current) { @@ -63,19 +76,19 @@ export function useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid }: mtimeRef.current = next - rpc('reload.mcp', { session_id: sid }).then( + quietRpc(gw, 'reload.mcp', { session_id: sid }).then( r => r && turnController.pushActivity('MCP reloaded after config change') ) - rpc('config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) + quietRpc(gw, 'config.get', { key: 'full' }).then(r => applyDisplay(r, setBellOnComplete)) }) }, MTIME_POLL_MS) return () => clearInterval(id) - }, [rpc, setBellOnComplete, sid]) + }, [gw, setBellOnComplete, sid]) } export interface UseConfigSyncOptions { - rpc: GatewayRpc + gw: GatewayClient setBellOnComplete: (v: boolean) => void setVoiceEnabled: (v: boolean) => void sid: null | string diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 71ecab6ac5..70000b73c8 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -11,6 +11,7 @@ import type { import type { InputHandlerContext, InputHandlerResult } from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' +import { patchTurnState } from './turnStore.js' import { getUiState, patchUiState } from './uiStore.js' const isCtrl = (key: { ctrl: boolean }, ch: string, target: string) => key.ctrl && ch.toLowerCase() === target @@ -24,11 +25,17 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const pagerPageSize = Math.max(5, (terminal.stdout?.rows ?? 24) - 6) const copySelection = () => { - if (terminal.selection.copySelection()) { - actions.sys('copied selection') + const text = terminal.selection.copySelection() + + if (text) { + actions.sys(`copied ${text.length} chars`) } } + const clearSelection = () => { + terminal.selection.clearSelection() + } + const cancelOverlayFromCtrlC = () => { if (overlay.clarify) { return actions.answerClarify('') @@ -37,7 +44,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { if (overlay.approval) { return gateway .rpc('approval.respond', { choice: 'deny', session_id: getUiState().sid }) - .then(r => r && (patchOverlayState({ approval: null }), actions.sys('denied'))) + .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) } if (overlay.sudo) { @@ -215,6 +222,10 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return copySelection() } + if (key.escape && terminal.hasSelection) { + return clearSelection() + } + if (key.upArrow && !cState.inputBuf.length) { cycleQueue(1) || cycleHistory(-1) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index cfc471e0a9..df4a0b3b82 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -280,7 +280,7 @@ export function useMainApp(gw: GatewayClient) { sys }) - useConfigSync({ rpc, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) + useConfigSync({ gw, setBellOnComplete, setVoiceEnabled, sid: ui.sid }) useEffect(() => { if (!ui.sid || !stdout) { @@ -516,10 +516,10 @@ export function useMainApp(gw: GatewayClient) { (choice: string) => respondWith('approval.respond', { choice, session_id: ui.sid }, () => { patchOverlayState({ approval: null }) - sys(choice === 'deny' ? 'denied' : `approved (${choice})`) + patchTurnState({ outcome: choice === 'deny' ? 'denied' : `approved (${choice})` }) patchUiState({ status: 'running…' }) }), - [respondWith, sys, ui.sid] + [respondWith, ui.sid] ) const answerSudo = useCallback( @@ -562,6 +562,7 @@ export function useMainApp(gw: GatewayClient) { ? turn.activity.some(item => item.tone !== 'info') : Boolean( ui.busy || + turn.outcome || turn.subagents.length || turn.tools.length || turn.turnTrail.length || diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index ca0edfea3f..dcadb8226d 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -10,7 +10,7 @@ import type { Theme } from '../theme.js' import type { DetailsMode } from '../types.js' import { GoodVibesHeart, StatusRule, StickyPromptTracker, TranscriptScrollbar } from './appChrome.js' -import { AppOverlays } from './appOverlays.js' +import { FloatingOverlays, PromptZone } from './appOverlays.js' import { Banner, Panel, SessionPanel } from './branding.js' import { MessageLine } from './messageLine.js' import { QueuedMessages } from './queuedMessages.js' @@ -37,6 +37,7 @@ const StreamingAssistant = memo(function StreamingAssistant({ activity={progress.activity} busy={busy} detailsMode={detailsMode} + outcome={progress.outcome} reasoning={progress.reasoning} reasoningActive={progress.reasoningActive} reasoningStreaming={progress.reasoningStreaming} @@ -179,16 +180,12 @@ const ComposerPane = memo(function ComposerPane({ /> )} - @@ -254,6 +251,14 @@ export const AppLayout = memo(function AppLayout({ + + diff --git a/ui-tui/src/components/appOverlays.tsx b/ui-tui/src/components/appOverlays.tsx index 509a990cdb..23187cf3f9 100644 --- a/ui-tui/src/components/appOverlays.tsx +++ b/ui-tui/src/components/appOverlays.tsx @@ -12,31 +12,77 @@ import { ModelPicker } from './modelPicker.js' import { ApprovalPrompt, ClarifyPrompt } from './prompts.js' import { SessionPicker } from './sessionPicker.js' -export function AppOverlays({ +export function PromptZone({ + cols, + onApprovalChoice, + onClarifyAnswer, + onSecretSubmit, + onSudoSubmit +}: Pick) { + const overlay = useStore($overlayState) + const ui = useStore($uiState) + + if (overlay.approval) { + return ( + + + + ) + } + + if (overlay.clarify) { + return ( + + onClarifyAnswer('')} + req={overlay.clarify} + t={ui.theme} + /> + + ) + } + + if (overlay.sudo) { + return ( + + + + ) + } + + if (overlay.secret) { + return ( + + + + ) + } + + return null +} + +export function FloatingOverlays({ cols, compIdx, completions, - onApprovalChoice, - onClarifyAnswer, onModelSelect, onPickerSelect, - onSecretSubmit, - onSudoSubmit, pagerPageSize -}: AppOverlaysProps) { +}: Pick) { const { gw } = useGateway() const overlay = useStore($overlayState) const ui = useStore($uiState) - const hasAny = - overlay.approval || - overlay.clarify || - overlay.modelPicker || - overlay.pager || - overlay.picker || - overlay.secret || - overlay.sudo || - completions.length + const hasAny = overlay.modelPicker || overlay.pager || overlay.picker || completions.length if (!hasAny) { return null @@ -46,43 +92,6 @@ export function AppOverlays({ return ( - {overlay.clarify && ( - - onClarifyAnswer('')} - req={overlay.clarify} - t={ui.theme} - /> - - )} - - {overlay.approval && ( - - - - )} - - {overlay.sudo && ( - - - - )} - - {overlay.secret && ( - - - - )} - {overlay.picker && ( { if (key.upArrow && sel > 0) { setSel(s => s - 1) } - if (key.downArrow && sel < 3) { + if (key.downArrow && sel < OPTS.length - 1) { setSel(s => s + 1) } + const n = parseInt(ch, 10) + + if (n >= 1 && n <= OPTS.length) { + onChoice(OPTS[n - 1]!) + + return + } + 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} + ⚠ approval required · {req.description} - {req.command} + {req.command} {OPTS.map((o, i) => ( {sel === i ? '▸ ' : ' '} - [{o[0]}] {LABELS[o]} + {i + 1}. {LABELS[o]} ))} - ↑/↓ select · Enter confirm · o/s/a/d quick pick + ↑/↓ select · Enter confirm · 1-4 quick pick · Ctrl+C deny ) } diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 4825373762..2e7791e25c 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -1,5 +1,5 @@ -import * as Ink from '@hermes/ink' import type { InputEvent, Key } from '@hermes/ink' +import * as Ink from '@hermes/ink' import { useEffect, useMemo, useRef, useState } from 'react' type InkExt = typeof Ink & { @@ -240,6 +240,14 @@ function renderWithCursor(value: string, cursor: number) { return done ? out : out + invert(' ') } +function renderWithSelection(value: string, start: number, end: number) { + if (start >= end) { + return value + } + + return value.slice(0, start) + invert(value.slice(start, end) || ' ') + value.slice(end) +} + function useFwdDelete(active: boolean) { const ref = useRef(false) const { inputEmitter: ee } = useStdin() @@ -274,13 +282,16 @@ export function TextInput({ focus = true }: TextInputProps) { const [cur, setCur] = useState(value.length) + const [sel, setSel] = useState(null) const fwdDel = useFwdDelete(focus) const termFocus = useTerminalFocus() const curRef = useRef(cur) + const selRef = useRef(null) const vRef = useRef(value) const self = useRef(false) const pasteBuf = useRef('') + const pasteEnd = useRef(null) const pasteTimer = useRef | null>(null) const pastePos = useRef(0) const undo = useRef<{ cursor: number; value: string }[]>([]) @@ -296,12 +307,15 @@ export function TextInput({ const raw = self.current ? vRef.current : value const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw + const selected = + sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null + const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display]) const boxRef = useDeclaredCursor({ line: layout.line, column: layout.column, - active: focus && termFocus + active: focus && termFocus && !selected }) const rendered = useMemo(() => { @@ -313,15 +327,21 @@ export function TextInput({ return invert(placeholder[0] ?? ' ') + dim(placeholder.slice(1)) } + if (selected) { + return renderWithSelection(display, selected.start, selected.end) + } + return renderWithCursor(display, cur) - }, [cur, display, focus, placeholder]) + }, [cur, display, focus, placeholder, selected]) useEffect(() => { if (self.current) { self.current = false } else { setCur(value.length) + setSel(null) curRef.current = value.length + selRef.current = null vRef.current = value undo.current = [] redo.current = [] @@ -341,6 +361,11 @@ export function TextInput({ const prev = vRef.current const c = snapPos(next, nextCur) + if (selRef.current) { + selRef.current = null + setSel(null) + } + if (track && next !== prev) { undo.current.push({ cursor: curRef.current, value: prev }) @@ -385,7 +410,9 @@ export function TextInput({ const flushPaste = () => { const text = pasteBuf.current const at = pastePos.current + const end = pasteEnd.current ?? at pasteBuf.current = '' + pasteEnd.current = null pasteTimer.current = null if (!text) { @@ -393,10 +420,41 @@ export function TextInput({ } if (!emitPaste({ cursor: at, text, value: vRef.current }) && PRINTABLE.test(text)) { - commit(vRef.current.slice(0, at) + text + vRef.current.slice(at), at + text.length) + commit(vRef.current.slice(0, at) + text + vRef.current.slice(end), at + text.length) } } + const clearSel = () => { + if (!selRef.current) { + return + } + + selRef.current = null + setSel(null) + } + + const selectAll = () => { + const end = vRef.current.length + + if (!end) { + return + } + + const next = { end, start: 0 } + selRef.current = next + setSel(next) + setCur(end) + curRef.current = end + } + + const selRange = () => { + const range = selRef.current + + return range && range.start !== range.end + ? { end: Math.max(range.start, range.end), start: Math.min(range.start, range.end) } + : null + } + const ins = (v: string, c: number, s: string) => v.slice(0, c) + s + v.slice(c) useInput( @@ -431,6 +489,8 @@ export function TextInput({ let c = curRef.current let v = vRef.current const mod = k.ctrl || k.meta + const range = selRange() + const delFwd = k.delete || fwdDel.current if (k.ctrl && inp === 'z') { return swap(undo, redo) @@ -440,19 +500,42 @@ export function TextInput({ return swap(redo, undo) } - if (k.home || (k.ctrl && inp === 'a')) { + if (k.ctrl && inp === 'a') { + return selectAll() + } + + if (k.home) { + clearSel() c = 0 } else if (k.end || (k.ctrl && inp === 'e')) { + clearSel() c = v.length } else if (k.leftArrow) { - c = mod ? wordLeft(v, c) : prevPos(v, c) + if (range && !mod) { + clearSel() + c = range.start + } else { + clearSel() + c = mod ? wordLeft(v, c) : prevPos(v, c) + } } else if (k.rightArrow) { - c = mod ? wordRight(v, c) : nextPos(v, c) + if (range && !mod) { + clearSel() + c = range.end + } else { + clearSel() + c = mod ? wordRight(v, c) : nextPos(v, c) + } } else if (k.meta && inp === 'b') { + clearSel() c = wordLeft(v, c) } else if (k.meta && inp === 'f') { + clearSel() c = wordRight(v, c) - } else if ((k.backspace || k.delete) && !fwdDel.current && c > 0) { + } else if (range && (k.backspace || delFwd)) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else if (k.backspace && c > 0) { if (mod) { const t = wordLeft(v, c) v = v.slice(0, t) + v.slice(c) @@ -462,22 +545,40 @@ export function TextInput({ v = v.slice(0, t) + v.slice(c) c = t } - } else if (k.delete && fwdDel.current && c < v.length) { + } else if (delFwd && c < v.length) { if (mod) { const t = wordRight(v, c) v = v.slice(0, c) + v.slice(t) } else { v = v.slice(0, c) + v.slice(nextPos(v, c)) } - } else if (k.ctrl && inp === 'w' && c > 0) { - const t = wordLeft(v, c) - v = v.slice(0, t) + v.slice(c) - c = t + } else if (k.ctrl && inp === 'w') { + if (range) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else if (c > 0) { + clearSel() + const t = wordLeft(v, c) + v = v.slice(0, t) + v.slice(c) + c = t + } else { + return + } } else if (k.ctrl && inp === 'u') { - v = v.slice(c) - c = 0 + if (range) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else { + v = v.slice(c) + c = 0 + } } else if (k.ctrl && inp === 'k') { - v = v.slice(0, c) + if (range) { + v = v.slice(0, range.start) + v.slice(range.end) + c = range.start + } else { + v = v.slice(0, c) + } } else if (inp.length > 0) { const bracketed = inp.includes('[200~') const text = inp.replace(BRACKET_PASTE, '').replace(/\r\n/g, '\n').replace(/\r/g, '\n') @@ -496,7 +597,8 @@ export function TextInput({ if (text.length > 1 || text.includes('\n')) { if (!pasteBuf.current) { - pastePos.current = c + pastePos.current = range ? range.start : c + pasteEnd.current = range ? range.end : pastePos.current } pasteBuf.current += text @@ -511,8 +613,13 @@ export function TextInput({ } if (PRINTABLE.test(text)) { - v = v.slice(0, c) + text + v.slice(c) - c += text.length + if (range) { + v = v.slice(0, range.start) + text + v.slice(range.end) + c = range.start + text.length + } else { + v = v.slice(0, c) + text + v.slice(c) + c += text.length + } } else { return } @@ -532,6 +639,7 @@ export function TextInput({ return } + clearSel() const next = offsetFromPosition(display, e.localRow ?? 0, e.localCol ?? 0, columns) setCur(next) curRef.current = next diff --git a/ui-tui/src/components/thinking.tsx b/ui-tui/src/components/thinking.tsx index 25f2080818..958333d6e5 100644 --- a/ui-tui/src/components/thinking.tsx +++ b/ui-tui/src/components/thinking.tsx @@ -537,6 +537,7 @@ interface Group { export const ToolTrail = memo(function ToolTrail({ busy = false, detailsMode = 'collapsed', + outcome = '', reasoningActive = false, reasoning = '', reasoningTokens, @@ -550,6 +551,7 @@ export const ToolTrail = memo(function ToolTrail({ }: { busy?: boolean detailsMode?: DetailsMode + outcome?: string reasoningActive?: boolean reasoning?: string reasoningTokens?: number @@ -596,7 +598,16 @@ export const ToolTrail = memo(function ToolTrail({ const cot = useMemo(() => thinkingPreview(reasoning, 'full', THINKING_COT_MAX), [reasoning]) - if (!busy && !trail.length && !tools.length && !subagents.length && !activity.length && !cot && !reasoningActive) { + if ( + !busy && + !trail.length && + !tools.length && + !subagents.length && + !activity.length && + !cot && + !reasoningActive && + !outcome + ) { return null } @@ -961,6 +972,13 @@ export const ToolTrail = memo(function ToolTrail({ t={t} /> ) : null} + {outcome ? ( + + + · {outcome} + + + ) : null} ) }) From 0dd5055d596d7d2300e0f5c14a7e96926b6082e0 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 10:58:01 -0500 Subject: [PATCH 149/157] fix(tui): first-run setup preflight + actionable no-provider panel - tui_gateway: new `setup.status` RPC that reuses CLI's `_has_any_provider_configured()`, so the TUI can ask the same question the CLI bootstrap asks before launching a session - useSessionLifecycle: preflight `setup.status` before both `newSession` and `resumeById`, and render a clear "Setup Required" panel when no provider is configured instead of booting a session that immediately fails with `agent init failed` - createGatewayEventHandler: drop duplicate startup resume logic in favor of the preflighted `resumeById`, and special-case the no-provider agent-init error as a last-mile fallback to the same setup panel - add regression tests for both paths --- tests/test_tui_gateway_server.py | 8 ++ tui_gateway/server.py | 9 +++ .../createGatewayEventHandler.test.ts | 23 ++++++ ui-tui/src/app/createGatewayEventHandler.ts | 57 ++++++------- ui-tui/src/app/interfaces.ts | 2 + ui-tui/src/app/useMainApp.ts | 6 +- ui-tui/src/app/useSessionLifecycle.ts | 80 ++++++++++++------- ui-tui/src/content/setup.ts | 18 +++++ ui-tui/src/gatewayTypes.ts | 4 + 9 files changed, 145 insertions(+), 62 deletions(-) create mode 100644 ui-tui/src/content/setup.ts diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index e1170b1067..e7681b784c 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -129,6 +129,14 @@ def test_enable_gateway_prompts_sets_gateway_env(monkeypatch): assert server.os.environ["HERMES_INTERACTIVE"] == "1" +def test_setup_status_reports_provider_config(monkeypatch): + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False) + + resp = server.handle_request({"id": "1", "method": "setup.status", "params": {}}) + + assert resp["result"]["provider_configured"] is False + + def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): monkeypatch.setattr(server, "_hermes_home", tmp_path) agent = types.SimpleNamespace(reasoning_config=None) diff --git a/tui_gateway/server.py b/tui_gateway/server.py index ff5e2cbf9c..3ef76a0f02 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1878,6 +1878,15 @@ def _(rid, params: dict) -> dict: return _err(rid, 4002, f"unknown config key: {key}") +@method("setup.status") +def _(rid, params: dict) -> dict: + try: + from hermes_cli.main import _has_any_provider_configured + return _ok(rid, {"provider_configured": bool(_has_any_provider_configured())}) + except Exception as e: + return _err(rid, 5016, str(e)) + + # ── Methods: tools & system ────────────────────────────────────────── @method("process.stop") diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 63675b8d3b..e546ce640e 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -26,6 +26,7 @@ const buildCtx = (appended: Msg[]) => colsRef: ref(80), newSession: vi.fn(), resetSession: vi.fn(), + resumeById: vi.fn(), setCatalog: vi.fn() }, system: { @@ -34,6 +35,8 @@ const buildCtx = (appended: Msg[]) => }, transcript: { appendMessage: (msg: Msg) => appended.push(msg), + panel: (title: string, sections: any[]) => + appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }), setHistoryItems: vi.fn() } }) as any @@ -138,4 +141,24 @@ describe('createGatewayEventHandler', () => { expect(appended[0]?.thinking).toBe(fromServer) expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) }) + + it('shows setup panel for missing provider startup error', () => { + const appended: Msg[] = [] + const onEvent = createGatewayEventHandler(buildCtx(appended)) + + onEvent({ + payload: { + message: + 'agent init failed: No LLM provider configured. Run `hermes model` to select a provider, or run `hermes setup` for first-time configuration.' + }, + type: 'error' + } as any) + + expect(appended).toHaveLength(1) + expect(appended[0]).toMatchObject({ + kind: 'panel', + panelData: { title: 'Setup Required' }, + role: 'system' + }) + }) }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 62b40f6193..8610b2551e 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -1,7 +1,7 @@ import { STREAM_BATCH_MS } from '../config/timing.js' -import { introMsg, toTranscriptMessages } from '../domain/messages.js' -import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin, SessionResumeResponse } from '../gatewayTypes.js' -import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' +import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' +import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js' +import { rpcErrorMessage } from '../lib/rpc.js' import { formatToolCall } from '../lib/text.js' import { fromSkin } from '../theme.js' import type { SubagentProgress } from '../types.js' @@ -12,6 +12,7 @@ import { turnController } from './turnController.js' import { getUiState, patchUiState } from './uiStore.js' const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i +const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') @@ -46,10 +47,10 @@ const pushTool = pushUnique(8) export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { const { dequeue, queueEditRef, sendQueued } = ctx.composer - const { gw, rpc } = ctx.gateway - const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session + const { rpc } = ctx.gateway + const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session const { bellOnComplete, stdout, sys } = ctx.system - const { appendMessage, setHistoryItems } = ctx.transcript + const { appendMessage, panel, setHistoryItems } = ctx.transcript let pendingThinkingStatus = '' let thinkingStatusTimer: null | ReturnType = null @@ -121,30 +122,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: } patchUiState({ status: 'resuming…' }) - gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) - .then(raw => { - const r = asRpcResult(raw) - - if (!r) { - throw new Error('invalid response: session.resume') - } - - resetSession() - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: r.info?.usage ?? getUiState().usage - }) - setHistoryItems( - r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages) - ) - }) - .catch((e: unknown) => { - sys(`resume failed: ${rpcErrorMessage(e)}`) - patchUiState({ status: 'forging session…' }) - newSession('started a new session') - }) + resumeById(STARTUP_RESUME_ID) } return (ev: GatewayEvent) => { @@ -438,9 +416,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: case 'error': turnController.recordError() - turnController.pushActivity(String(ev.payload?.message || 'unknown error'), 'error') - sys(`error: ${ev.payload?.message}`) - setStatus('ready') + + { + const message = String(ev.payload?.message || 'unknown error') + + turnController.pushActivity(message, 'error') + + if (NO_PROVIDER_RE.test(message)) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + setStatus('setup required') + + return + } + + sys(`error: ${message}`) + setStatus('ready') + } } } } diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index d14536624a..9af6e5dc64 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -197,6 +197,7 @@ export interface GatewayEventHandlerContext { colsRef: MutableRefObject newSession: (msg?: string) => void resetSession: () => void + resumeById: (id: string) => void setCatalog: StateSetter } system: { @@ -206,6 +207,7 @@ export interface GatewayEventHandlerContext { } transcript: { appendMessage: (msg: Msg) => void + panel: (title: string, sections: PanelSection[]) => void setHistoryItems: StateSetter } } diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index df4a0b3b82..f83633cdab 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { useApp, useHasSelection, useSelection, useStdout, type ScrollBoxHandle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -270,6 +270,7 @@ export function useMainApp(gw: GatewayClient) { colsRef, composerActions, gw, + panel, rpc, setHistoryItems, setLastUserMsg, @@ -413,10 +414,11 @@ export function useMainApp(gw: GatewayClient) { colsRef, newSession: session.newSession, resetSession: session.resetSession, + resumeById: session.resumeById, setCatalog }, system: { bellOnComplete, stdout, sys }, - transcript: { appendMessage, setHistoryItems } + transcript: { appendMessage, panel, setHistoryItems } }), [ appendMessage, diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 83341108e0..d54e9ae96e 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,9 +1,15 @@ import { useCallback } from 'react' +import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' import { ZERO } from '../domain/usage.js' import { type GatewayClient } from '../gatewayClient.js' -import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse } from '../gatewayTypes.js' +import type { + SessionCloseResponse, + SessionCreateResponse, + SessionResumeResponse, + SetupStatusResponse +} from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' import type { Msg, SessionInfo, Usage } from '../types.js' @@ -33,6 +39,7 @@ export interface UseSessionLifecycleOptions { colsRef: { current: number } composerActions: ComposerActions gw: GatewayClient + panel: (title: string, sections: import('../types.js').PanelSection[]) => void rpc: GatewayRpc setHistoryItems: StateSetter setLastUserMsg: StateSetter @@ -48,6 +55,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { colsRef, composerActions, gw, + panel, rpc, setHistoryItems, setLastUserMsg, @@ -94,6 +102,15 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { const newSession = useCallback( async (msg?: string) => { + const setup = await rpc('setup.status', {}) + + if (setup?.provider_configured === false) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + patchUiState({ status: 'setup required' }) + + return + } + await closeSession(getUiState().sid) const r = await rpc('session.create', { cols: colsRef.current }) @@ -126,7 +143,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { sys(msg) } }, - [closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) const resumeById = useCallback( @@ -134,38 +151,47 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { patchOverlayState({ picker: false }) patchUiState({ status: 'resuming…' }) - closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => - gw - .request('session.resume', { cols: colsRef.current, session_id: id }) - .then(raw => { - const r = asRpcResult(raw) + rpc('setup.status', {}).then(setup => { + if (setup?.provider_configured === false) { + panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections()) + patchUiState({ status: 'setup required' }) - if (!r) { - sys('error: invalid response: session.resume') + return + } - return patchUiState({ status: 'ready' }) - } + closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => + gw + .request('session.resume', { cols: colsRef.current, session_id: id }) + .then(raw => { + const r = asRpcResult(raw) - resetSession() - setSessionStartedAt(Date.now()) + if (!r) { + sys('error: invalid response: session.resume') - const resumed = toTranscriptMessages(r.messages) + return patchUiState({ status: 'ready' }) + } - setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) - patchUiState({ - info: r.info ?? null, - sid: r.session_id, - status: 'ready', - usage: usageFrom(r.info ?? null) + resetSession() + setSessionStartedAt(Date.now()) + + const resumed = toTranscriptMessages(r.messages) + + setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) + patchUiState({ + info: r.info ?? null, + sid: r.session_id, + status: 'ready', + usage: usageFrom(r.info ?? null) + }) }) - }) - .catch((e: Error) => { - sys(`error: ${e.message}`) - patchUiState({ status: 'ready' }) - }) - ) + .catch((e: Error) => { + sys(`error: ${e.message}`) + patchUiState({ status: 'ready' }) + }) + ) + }) }, - [closeSession, colsRef, gw, resetSession, setHistoryItems, setSessionStartedAt, sys] + [closeSession, colsRef, gw, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] ) const guardBusySessionSwitch = useCallback( diff --git a/ui-tui/src/content/setup.ts b/ui-tui/src/content/setup.ts new file mode 100644 index 0000000000..1170b638d5 --- /dev/null +++ b/ui-tui/src/content/setup.ts @@ -0,0 +1,18 @@ +import type { PanelSection } from '../types.js' + +export const SETUP_REQUIRED_TITLE = 'Setup Required' + +export const buildSetupRequiredSections = (): PanelSection[] => [ + { + text: 'Hermes needs a model provider before the TUI can start a session.' + }, + { + rows: [ + ['1.', 'Exit with Ctrl+C'], + ['2.', 'Run `hermes model` to choose a provider + model'], + ['3.', 'Or run `hermes setup` for full first-time setup'], + ['4.', 'Re-open `hermes --tui` when setup is done'] + ], + title: 'Next Steps' + } +] diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 31c58896b8..9e21b9bc58 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -80,6 +80,10 @@ export interface ConfigSetResponse { warning?: string } +export interface SetupStatusResponse { + provider_configured?: boolean +} + // ── Session lifecycle ──────────────────────────────────────────────── export interface SessionCreateResponse { From a82097e7a211e8a3a15e492be47db604ebc3ad4e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 10:58:18 -0500 Subject: [PATCH 150/157] feat(tui): /model and /setup slash commands with in-place CLI handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hermes-ink: export `withInkSuspended()` + `useExternalProcess()` that pause/resume Ink around an arbitrary external process (built on the existing enterAlternateScreen/exitAlternateScreen plumbing) - tui: `launchHermesCommand(args)` spawns the `hermes` binary with inherited stdio, with `HERMES_BIN` override for non-standard launches - tui: `/model` and `/setup` slash commands invoke the CLI wizards in-place, then re-preflight `setup.status` and auto-start a session on success — no more exit-and-relaunch to finish first-run setup - setup panel now advertises those slashes instead of only pointing users back at the shell --- .../packages/hermes-ink/src/entry-exports.ts | 3 +- .../src/ink/hooks/use-external-process.ts | 27 ++++++++++ ui-tui/src/app/setupHandoff.ts | 54 +++++++++++++++++++ ui-tui/src/app/slash/commands/setup.ts | 33 ++++++++++++ ui-tui/src/app/slash/registry.ts | 3 +- ui-tui/src/content/setup.ts | 9 ++-- ui-tui/src/lib/externalCli.ts | 16 ++++++ ui-tui/src/types/hermes-ink.d.ts | 3 ++ 8 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts create mode 100644 ui-tui/src/app/setupHandoff.ts create mode 100644 ui-tui/src/app/slash/commands/setup.ts create mode 100644 ui-tui/src/lib/externalCli.ts diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index d9fd98deed..58c4e69763 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -1,3 +1,4 @@ +export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' export { default as useStderr } from './hooks/use-stderr.js' export { default as useStdout } from './hooks/use-stdout.js' export { Ansi } from './ink/Ansi.js' @@ -12,6 +13,7 @@ export { default as Spacer } from './ink/components/Spacer.js' export { default as Text } from './ink/components/Text.js' export { default as useApp } from './ink/hooks/use-app.js' export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js' +export { useExternalProcess, withInkSuspended, type RunExternalProcess } from './ink/hooks/use-external-process.js' export { default as useInput } from './ink/hooks/use-input.js' export { useHasSelection, useSelection } from './ink/hooks/use-selection.js' export { default as useStdin } from './ink/hooks/use-stdin.js' @@ -22,4 +24,3 @@ export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' export { createRoot, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' -export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts b/ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts new file mode 100644 index 0000000000..c895edeb21 --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/hooks/use-external-process.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' + +import instances from '../instances.js' + +export type RunExternalProcess = () => Promise + +export async function withInkSuspended(run: RunExternalProcess): Promise { + const ink = instances.get(process.stdout) + + if (!ink) { + await run() + + return + } + + ink.enterAlternateScreen() + + try { + await run() + } finally { + ink.exitAlternateScreen() + } +} + +export function useExternalProcess(): (run: RunExternalProcess) => Promise { + return useCallback((run: RunExternalProcess) => withInkSuspended(run), []) +} diff --git a/ui-tui/src/app/setupHandoff.ts b/ui-tui/src/app/setupHandoff.ts new file mode 100644 index 0000000000..21338c95e4 --- /dev/null +++ b/ui-tui/src/app/setupHandoff.ts @@ -0,0 +1,54 @@ +import type { RunExternalProcess } from '@hermes/ink' + +import type { SetupStatusResponse } from '../gatewayTypes.js' +import type { LaunchResult } from '../lib/externalCli.js' + +import type { SlashHandlerContext } from './interfaces.js' +import { patchUiState } from './uiStore.js' + +export interface RunExternalSetupOptions { + args: string[] + ctx: Pick + done: string + launcher: (args: string[]) => Promise + suspend: (run: RunExternalProcess) => Promise +} + +export async function runExternalSetup({ args, ctx, done, launcher, suspend }: RunExternalSetupOptions) { + const { gateway, session, transcript } = ctx + + transcript.sys(`launching \`hermes ${args.join(' ')}\`…`) + patchUiState({ status: 'setup running…' }) + + let result: LaunchResult = { code: null } + + await suspend(async () => { + result = await launcher(args) + }) + + if (result.error) { + transcript.sys(`error launching hermes: ${result.error}`) + patchUiState({ status: 'setup required' }) + + return + } + + if (result.code !== 0) { + transcript.sys(`hermes ${args[0]} exited with code ${result.code}`) + patchUiState({ status: 'setup required' }) + + return + } + + const setup = await gateway.rpc('setup.status', {}) + + if (setup?.provider_configured === false) { + transcript.sys('still no provider configured') + patchUiState({ status: 'setup required' }) + + return + } + + transcript.sys(done) + session.newSession() +} diff --git a/ui-tui/src/app/slash/commands/setup.ts b/ui-tui/src/app/slash/commands/setup.ts new file mode 100644 index 0000000000..c6d5cc8637 --- /dev/null +++ b/ui-tui/src/app/slash/commands/setup.ts @@ -0,0 +1,33 @@ +import { withInkSuspended } from '@hermes/ink' + +import { launchHermesCommand } from '../../../lib/externalCli.js' +import { runExternalSetup } from '../../setupHandoff.js' +import type { SlashCommand } from '../types.js' + +export const setupCommands: SlashCommand[] = [ + { + aliases: ['provider'], + help: 'configure LLM provider and model (launches `hermes model`)', + name: 'model', + run: (_arg, ctx) => + void runExternalSetup({ + args: ['model'], + ctx, + done: 'provider updated — starting session…', + launcher: launchHermesCommand, + suspend: withInkSuspended + }) + }, + { + help: 'run full setup wizard (launches `hermes setup`)', + name: 'setup', + run: (arg, ctx) => + void runExternalSetup({ + args: ['setup', ...arg.split(/\s+/).filter(Boolean)], + ctx, + done: 'setup complete — starting session…', + launcher: launchHermesCommand, + suspend: withInkSuspended + }) + } +] diff --git a/ui-tui/src/app/slash/registry.ts b/ui-tui/src/app/slash/registry.ts index 6a59d06384..ae7d7d50be 100644 --- a/ui-tui/src/app/slash/registry.ts +++ b/ui-tui/src/app/slash/registry.ts @@ -1,9 +1,10 @@ import { coreCommands } from './commands/core.js' import { opsCommands } from './commands/ops.js' import { sessionCommands } from './commands/session.js' +import { setupCommands } from './commands/setup.js' import type { SlashCommand } from './types.js' -export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands] +export const SLASH_COMMANDS: SlashCommand[] = [...coreCommands, ...sessionCommands, ...opsCommands, ...setupCommands] const byName = new Map( SLASH_COMMANDS.flatMap(cmd => [cmd.name, ...(cmd.aliases ?? [])].map(name => [name, cmd] as const)) diff --git a/ui-tui/src/content/setup.ts b/ui-tui/src/content/setup.ts index 1170b638d5..49dd9aa247 100644 --- a/ui-tui/src/content/setup.ts +++ b/ui-tui/src/content/setup.ts @@ -8,11 +8,10 @@ export const buildSetupRequiredSections = (): PanelSection[] => [ }, { rows: [ - ['1.', 'Exit with Ctrl+C'], - ['2.', 'Run `hermes model` to choose a provider + model'], - ['3.', 'Or run `hermes setup` for full first-time setup'], - ['4.', 'Re-open `hermes --tui` when setup is done'] + ['/model', 'configure provider + model in-place'], + ['/setup', 'run full first-time setup wizard in-place'], + ['Ctrl+C', 'exit and run `hermes setup` manually'] ], - title: 'Next Steps' + title: 'Actions' } ] diff --git a/ui-tui/src/lib/externalCli.ts b/ui-tui/src/lib/externalCli.ts new file mode 100644 index 0000000000..7ff88f2b86 --- /dev/null +++ b/ui-tui/src/lib/externalCli.ts @@ -0,0 +1,16 @@ +import { spawn } from 'node:child_process' + +export interface LaunchResult { + code: null | number + error?: string +} + +const resolveHermesBin = () => process.env.HERMES_BIN?.trim() || 'hermes' + +export const launchHermesCommand = (args: string[]): Promise => + new Promise(resolve => { + const child = spawn(resolveHermesBin(), args, { stdio: 'inherit' }) + + child.on('error', err => resolve({ code: null, error: err.message })) + child.on('exit', code => resolve({ code })) + }) diff --git a/ui-tui/src/types/hermes-ink.d.ts b/ui-tui/src/types/hermes-ink.d.ts index d144656b33..9b2deec35f 100644 --- a/ui-tui/src/types/hermes-ink.d.ts +++ b/ui-tui/src/types/hermes-ink.d.ts @@ -72,6 +72,9 @@ declare module '@hermes/ink' { export function render(node: React.ReactNode, options?: NodeJS.WriteStream | RenderOptions): Instance export function useApp(): { readonly exit: (error?: Error) => void } + export type RunExternalProcess = () => Promise + export function useExternalProcess(): (run: RunExternalProcess) => Promise + export function withInkSuspended(run: RunExternalProcess): Promise export function useInput(handler: InputHandler, options?: { readonly isActive?: boolean }): void export function useSelection(): { readonly copySelection: () => string From 8f553a55b2a5e6b4e7e09c56b8860402430493a5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 11:00:15 -0500 Subject: [PATCH 151/157] chore(tui): fix eslint/prettier nits from npm run fix - drop inline `import()` type annotation in useSessionLifecycle (import `PanelSection` at the top like everything else) - include `panel` and `session.resumeById` in the useMainApp useMemo deps now that the event handler depends on them - wrap the derived `selected` range in a useMemo so it has stable identity and stops invalidating the TextInput `rendered` memo every render - prettier re-sorting of a couple of export/import lines --- ui-tui/packages/hermes-ink/src/entry-exports.ts | 4 ++-- ui-tui/src/app/useMainApp.ts | 4 +++- ui-tui/src/app/useSessionLifecycle.ts | 4 ++-- ui-tui/src/components/textInput.tsx | 7 +++++-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/entry-exports.ts b/ui-tui/packages/hermes-ink/src/entry-exports.ts index 58c4e69763..6ef1fc5fbd 100644 --- a/ui-tui/packages/hermes-ink/src/entry-exports.ts +++ b/ui-tui/packages/hermes-ink/src/entry-exports.ts @@ -1,4 +1,3 @@ -export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' export { default as useStderr } from './hooks/use-stderr.js' export { default as useStdout } from './hooks/use-stdout.js' export { Ansi } from './ink/Ansi.js' @@ -13,7 +12,7 @@ export { default as Spacer } from './ink/components/Spacer.js' export { default as Text } from './ink/components/Text.js' export { default as useApp } from './ink/hooks/use-app.js' export { useDeclaredCursor } from './ink/hooks/use-declared-cursor.js' -export { useExternalProcess, withInkSuspended, type RunExternalProcess } from './ink/hooks/use-external-process.js' +export { type RunExternalProcess, useExternalProcess, withInkSuspended } from './ink/hooks/use-external-process.js' export { default as useInput } from './ink/hooks/use-input.js' export { useHasSelection, useSelection } from './ink/hooks/use-selection.js' export { default as useStdin } from './ink/hooks/use-stdin.js' @@ -24,3 +23,4 @@ export { useTerminalViewport } from './ink/hooks/use-terminal-viewport.js' export { default as measureElement } from './ink/measure-element.js' export { createRoot, default as render, renderSync } from './ink/root.js' export { stringWidth } from './ink/stringWidth.js' +export { default as TextInput, UncontrolledTextInput } from 'ink-text-input' diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index f83633cdab..159489eb32 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { useApp, useHasSelection, useSelection, useStdout, type ScrollBoxHandle } from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -426,9 +426,11 @@ export function useMainApp(gw: GatewayClient) { composerActions, composerRefs, gateway, + panel, sendQueued, session.newSession, session.resetSession, + session.resumeById, stdout, sys ] diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index d54e9ae96e..8114916c62 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -11,7 +11,7 @@ import type { SetupStatusResponse } from '../gatewayTypes.js' import { asRpcResult } from '../lib/rpc.js' -import type { Msg, SessionInfo, Usage } from '../types.js' +import type { Msg, PanelSection, SessionInfo, Usage } from '../types.js' import type { ComposerActions, GatewayRpc, StateSetter } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' @@ -39,7 +39,7 @@ export interface UseSessionLifecycleOptions { colsRef: { current: number } composerActions: ComposerActions gw: GatewayClient - panel: (title: string, sections: import('../types.js').PanelSection[]) => void + panel: (title: string, sections: PanelSection[]) => void rpc: GatewayRpc setHistoryItems: StateSetter setLastUserMsg: StateSetter diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index 2e7791e25c..f2bbee63cf 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -307,8 +307,11 @@ export function TextInput({ const raw = self.current ? vRef.current : value const display = mask ? raw.replace(/[^\n]/g, mask[0] ?? '*') : raw - const selected = - sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null + const selected = useMemo( + () => + sel && sel.start !== sel.end ? { end: Math.max(sel.start, sel.end), start: Math.min(sel.start, sel.end) } : null, + [sel] + ) const layout = useMemo(() => cursorLayout(display, cur, columns), [columns, cur, display]) From 42721dbe1c686c88d7eb52eef6d01f53eab13836 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 11:04:29 -0500 Subject: [PATCH 152/157] fix(tui): big-session /resume now renders without first keystroke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useVirtualHistory set up its useSyncExternalStore subscription during the first render, when scrollRef.current was still null (the ScrollBox ref attaches during commit, after render). Its useCallback for subscribe had a stable scrollRef identity as its only dep, so it never re-subscribed once the ref actually attached — the hook stayed stuck with vp=0, top=0, no scroll subscription. Small sessions fit entirely in cold-start so you didn't notice; big /resume sessions got sliced to the last 40 items with a huge topSpacer and the viewport sat on empty space until some unrelated state change (e.g. a keystroke) re-rendered and finally read a real vp. - flip a hasScrollRef flag in useLayoutEffect once the ref attaches and add it to the subscribe useCallback deps so useSyncExternalStore rearms with a real subscription - on resume, scrollToBottom() after history hydrates so the ScrollBox lands at the newest messages instead of scrollTop=0 (stickyScroll doesn't auto-engage on the initial empty→full dump) --- ui-tui/src/app/useMainApp.ts | 1 + ui-tui/src/app/useSessionLifecycle.ts | 8 ++++++-- ui-tui/src/hooks/useVirtualHistory.ts | 10 +++++++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 159489eb32..8fa7c10619 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -272,6 +272,7 @@ export function useMainApp(gw: GatewayClient) { gw, panel, rpc, + scrollRef, setHistoryItems, setLastUserMsg, setSessionStartedAt, diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 8114916c62..e4da3e1bf8 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,4 +1,5 @@ -import { useCallback } from 'react' +import type { ScrollBoxHandle } from '@hermes/ink' +import { type RefObject, useCallback } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' @@ -41,6 +42,7 @@ export interface UseSessionLifecycleOptions { gw: GatewayClient panel: (title: string, sections: PanelSection[]) => void rpc: GatewayRpc + scrollRef: RefObject setHistoryItems: StateSetter setLastUserMsg: StateSetter setSessionStartedAt: StateSetter @@ -57,6 +59,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { gw, panel, rpc, + scrollRef, setHistoryItems, setLastUserMsg, setSessionStartedAt, @@ -183,6 +186,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { status: 'ready', usage: usageFrom(r.info ?? null) }) + queueMicrotask(() => scrollRef.current?.scrollToBottom()) }) .catch((e: Error) => { sys(`error: ${e.message}`) @@ -191,7 +195,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { ) }) }, - [closeSession, colsRef, gw, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] + [closeSession, colsRef, gw, panel, resetSession, rpc, scrollRef, setHistoryItems, setSessionStartedAt, sys] ) const guardBusySessionSwitch = useCallback( diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index b92c07c462..d33971fede 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -37,9 +37,17 @@ export function useVirtualHistory( const heights = useRef(new Map()) const refs = useRef(new Map void>()) const [ver, setVer] = useState(0) + const [hasScrollRef, setHasScrollRef] = useState(false) + + useLayoutEffect(() => { + setHasScrollRef(Boolean(scrollRef.current)) + }, [scrollRef]) useSyncExternalStore( - useCallback((cb: () => void) => scrollRef.current?.subscribe(cb) ?? (() => () => {}), [scrollRef]), + useCallback( + (cb: () => void) => (hasScrollRef ? scrollRef.current?.subscribe(cb) : null) ?? (() => () => {}), + [hasScrollRef, scrollRef] + ), () => { const s = scrollRef.current From be768db6276b1dc2cd6313bb6f266e9437d751ba Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 11:05:23 -0500 Subject: [PATCH 153/157] fix: long history session thingy --- ui-tui/src/app/useMainApp.ts | 2 +- ui-tui/src/app/useSessionLifecycle.ts | 2 +- ui-tui/src/hooks/useVirtualHistory.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 8fa7c10619..386b2f363f 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' +import { useApp, useHasSelection, useSelection, useStdout, type ScrollBoxHandle } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index e4da3e1bf8..1520033b0c 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,5 +1,5 @@ import type { ScrollBoxHandle } from '@hermes/ink' -import { type RefObject, useCallback } from 'react' +import { useCallback, type RefObject } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index d33971fede..4a99c29cd1 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -1,13 +1,13 @@ import type { ScrollBoxHandle } from '@hermes/ink' import { - type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, - useSyncExternalStore + useSyncExternalStore, + type RefObject } from 'react' const ESTIMATE = 4 From 00591e38015f8c58db624454f16ef35b99df25b7 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 11:06:25 -0500 Subject: [PATCH 154/157] chore: fmt --- ui-tui/src/app/useMainApp.ts | 2 +- ui-tui/src/app/useSessionLifecycle.ts | 2 +- ui-tui/src/hooks/useVirtualHistory.ts | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 386b2f363f..8fa7c10619 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -1,4 +1,4 @@ -import { useApp, useHasSelection, useSelection, useStdout, type ScrollBoxHandle } from '@hermes/ink' +import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index 1520033b0c..e4da3e1bf8 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -1,5 +1,5 @@ import type { ScrollBoxHandle } from '@hermes/ink' -import { useCallback, type RefObject } from 'react' +import { type RefObject, useCallback } from 'react' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js' diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index 4a99c29cd1..d33971fede 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -1,13 +1,13 @@ import type { ScrollBoxHandle } from '@hermes/ink' import { + type RefObject, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, - useSyncExternalStore, - type RefObject + useSyncExternalStore } from 'react' const ESTIMATE = 4 From f53250b5e1c5f43af01b580d37352b5eabae6e0e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 11:33:14 -0500 Subject: [PATCH 155/157] fix(tui): tighten /resume render, follow-up to 42721dbe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useVirtualHistory: track last-seen ScrollBox metrics in a ref inside the post-layout effect and bump ver when sticky/top/vp change — the subscribe-based rearm was sufficient for fresh clicks but not for the "hydrated mid-commit, measured empty, then metrics settle" path where nothing re-triggered the hook until the next unrelated keystroke - useSessionLifecycle: resume scrollToBottom from queueMicrotask to setTimeout(..., 0) so the fresh transcript has a full task turn to commit + measure before we try to land at the newest content --- ui-tui/src/app/useSessionLifecycle.ts | 2 +- ui-tui/src/hooks/useVirtualHistory.ts | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index e4da3e1bf8..acd10135e1 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -186,7 +186,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) { status: 'ready', usage: usageFrom(r.info ?? null) }) - queueMicrotask(() => scrollRef.current?.scrollToBottom()) + setTimeout(() => scrollRef.current?.scrollToBottom(), 0) }) .catch((e: Error) => { sys(`error: ${e.message}`) diff --git a/ui-tui/src/hooks/useVirtualHistory.ts b/ui-tui/src/hooks/useVirtualHistory.ts index d33971fede..efa2642df3 100644 --- a/ui-tui/src/hooks/useVirtualHistory.ts +++ b/ui-tui/src/hooks/useVirtualHistory.ts @@ -38,6 +38,7 @@ export function useVirtualHistory( const refs = useRef(new Map void>()) const [ver, setVer] = useState(0) const [hasScrollRef, setHasScrollRef] = useState(false) + const metrics = useRef({ sticky: true, top: 0, vp: 0 }) useLayoutEffect(() => { setHasScrollRef(Boolean(scrollRef.current)) @@ -141,10 +142,29 @@ export function useVirtualHistory( } } + const s = scrollRef.current + + if (s) { + const next = { + sticky: s.isSticky(), + top: Math.max(0, s.getScrollTop() + s.getPendingDelta()), + vp: Math.max(0, s.getViewportHeight()) + } + + if ( + next.sticky !== metrics.current.sticky || + next.top !== metrics.current.top || + next.vp !== metrics.current.vp + ) { + metrics.current = next + dirty = true + } + } + if (dirty) { setVer(v => v + 1) } - }, [end, items, start]) + }, [end, hasScrollRef, items, scrollRef, start]) return { bottomSpacer: Math.max(0, total - (offsets[end] ?? total)), From bedbeebbc8adf35940ce8b50c45306b5e42fbd28 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 11:33:29 -0500 Subject: [PATCH 156/157] feat(tui): interleave tool rows into live assistant turns Live turn rendering used to show the streaming assistant text as one blob with tool calls pooled in a separate section below, so the live view drifted from the reload view (which threads tool rows inline via toTranscriptMessages). Model now mirrors reload: - turnStore gains streamSegments (completed assistant chunks, each with any tool rows that landed between its predecessor and itself) and streamPendingTools (tool rows waiting for the next chunk) - turnController.flushStreamingSegment() seals the current bufRef into a segment when a new tool.start fires; pending tools get attached to that next chunk so order matches reload hydration - recordMessageComplete returns finalMessages instead of one payload, so appendMessage gets the same shape for live-ending turns as for reloaded ones - appLayout renders segments before the progress/streaming area, and the streaming message + pending-tools fallback carry whatever tools arrived after the last assistant chunk --- ui-tui/src/app/createGatewayEventHandler.ts | 15 ++--- ui-tui/src/app/interfaces.ts | 2 + ui-tui/src/app/turnController.ts | 62 ++++++++++++++++++--- ui-tui/src/app/turnStore.ts | 6 +- ui-tui/src/app/useMainApp.ts | 2 + ui-tui/src/components/appLayout.tsx | 20 ++++++- 6 files changed, 85 insertions(+), 22 deletions(-) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 8610b2551e..e728f8bbd0 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -4,7 +4,7 @@ import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gate import { rpcErrorMessage } from '../lib/rpc.js' import { formatToolCall } from '../lib/text.js' import { fromSkin } from '../theme.js' -import type { SubagentProgress } from '../types.js' +import type { Msg, SubagentProgress } from '../types.js' import type { GatewayEventHandlerContext } from './interfaces.js' import { patchOverlayState } from './overlayStore.js' @@ -377,18 +377,11 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'message.complete': { - const { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } = - turnController.recordMessageComplete(ev.payload ?? {}) + const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {}) if (!wasInterrupted) { - appendMessage({ - role: 'assistant', - text: finalText, - thinking: savedReasoning || undefined, - thinkingTokens: savedReasoning ? savedReasoningTokens : undefined, - toolTokens: savedTools.length ? savedToolTokens : undefined, - tools: savedTools.length ? savedTools : undefined - }) + const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }] + msgs.forEach(appendMessage) if (bellOnComplete && stdout?.isTTY) { stdout.write('\x07') diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index 9af6e5dc64..998afe2a19 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -285,6 +285,8 @@ export interface AppLayoutProgressProps { reasoningTokens: number showProgressArea: boolean showStreamingArea: boolean + streamPendingTools: string[] + streamSegments: Msg[] streaming: string subagents: SubagentProgress[] toolTokens: number diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index be25dcbbe8..73d0571734 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -3,7 +3,6 @@ import type { SessionInterruptResponse, SubagentEventPayload } from '../gatewayT import { buildToolTrailLine, estimateTokensRough, - isToolTrailResultLine, isTransientTrailLine, sameToolTrailGroup, toolTrailLabel @@ -42,6 +41,8 @@ class TurnController { persistedToolLabels = new Set() protocolWarned = false reasoningText = '' + segmentMessages: Msg[] = [] + pendingSegmentTools: string[] = [] statusTimer: Timer = null toolTokenAcc = 0 turnTools: string[] = [] @@ -74,8 +75,17 @@ class TurnController { this.activeTools = [] this.streamTimer = clear(this.streamTimer) this.bufRef = '' + this.pendingSegmentTools = [] + this.segmentMessages = [] - patchTurnState({ streaming: '', subagents: [], tools: [], turnTrail: [] }) + patchTurnState({ + streamPendingTools: [], + streamSegments: [], + streaming: '', + subagents: [], + tools: [], + turnTrail: [] + }) patchUiState({ busy: false }) resetOverlayState() } @@ -110,6 +120,22 @@ class TurnController { }) } + flushStreamingSegment() { + const text = this.bufRef.trimStart() + + if (!text) { + return + } + + const tools = this.pendingSegmentTools + + this.streamTimer = clear(this.streamTimer) + this.segmentMessages = [...this.segmentMessages, { role: 'assistant', text, ...(tools.length && { tools }) }] + this.bufRef = '' + this.pendingSegmentTools = [] + patchTurnState({ streamPendingTools: [], streamSegments: this.segmentMessages, streaming: '' }) + } + pulseReasoningStreaming() { this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) patchTurnState({ reasoningActive: true, reasoningStreaming: true }) @@ -154,6 +180,8 @@ class TurnController { this.idle() this.clearReasoning() this.clearStatusTimer() + this.pendingSegmentTools = [] + this.segmentMessages = [] this.turnTools = [] this.persistedToolLabels.clear() } @@ -163,11 +191,19 @@ class TurnController { const savedReasoning = this.reasoningText.trim() || String(payload.reasoning ?? '').trim() const savedReasoningTokens = savedReasoning ? estimateTokensRough(savedReasoning) : 0 const savedToolTokens = this.toolTokenAcc - const persisted = [...this.persistedToolLabels] + const tools = this.pendingSegmentTools + const finalMessages = [...this.segmentMessages] - const savedTools = this.turnTools.filter( - line => isToolTrailResultLine(line) && !persisted.some(label => sameToolTrailGroup(label, line)) - ) + if (finalText) { + finalMessages.push({ + role: 'assistant', + text: finalText, + thinking: savedReasoning || undefined, + thinkingTokens: savedReasoning ? savedReasoningTokens : undefined, + toolTokens: savedToolTokens || undefined, + ...(tools.length && { tools }) + }) + } const wasInterrupted = this.interrupted @@ -178,7 +214,7 @@ class TurnController { this.bufRef = '' patchTurnState({ activity: [], outcome: '' }) - return { finalText, savedReasoning, savedReasoningTokens, savedTools, savedToolTokens, wasInterrupted } + return { finalMessages, finalText, wasInterrupted } } recordMessageDelta({ rendered, text }: { rendered?: string; text?: string }) { @@ -218,15 +254,20 @@ class TurnController { const line = buildToolTrailLine(name, done?.context || '', Boolean(error), error || summary || '') this.activeTools = this.activeTools.filter(tool => tool.id !== toolId) + this.pendingSegmentTools = [...this.pendingSegmentTools, line] - const next = [...this.turnTools.filter(item => !sameToolTrailGroup(label, item)), line] + const next = this.turnTools.filter(item => !sameToolTrailGroup(label, item)) if (!this.activeTools.length) { next.push('analyzing tool output…') } this.turnTools = next.slice(-TRAIL_LIMIT) - patchTurnState({ tools: this.activeTools, turnTrail: this.turnTools }) + patchTurnState({ + streamPendingTools: this.pendingSegmentTools, + tools: this.activeTools, + turnTrail: this.turnTools + }) } recordToolProgress(toolName: string, preview: string) { @@ -249,6 +290,7 @@ class TurnController { } recordToolStart(toolId: string, name: string, context: string) { + this.flushStreamingSegment() this.pruneTransient() this.endReasoningPhase() @@ -267,7 +309,9 @@ class TurnController { this.bufRef = '' this.interrupted = false this.lastStatusNote = '' + this.pendingSegmentTools = [] this.protocolWarned = false + this.segmentMessages = [] this.turnTools = [] this.toolTokenAcc = 0 this.persistedToolLabels.clear() diff --git a/ui-tui/src/app/turnStore.ts b/ui-tui/src/app/turnStore.ts index 9b7e04db78..148a50c196 100644 --- a/ui-tui/src/app/turnStore.ts +++ b/ui-tui/src/app/turnStore.ts @@ -1,6 +1,6 @@ import { atom } from 'nanostores' -import type { ActiveTool, ActivityItem, SubagentProgress } from '../types.js' +import type { ActiveTool, ActivityItem, Msg, SubagentProgress } from '../types.js' const buildTurnState = (): TurnState => ({ activity: [], @@ -9,6 +9,8 @@ const buildTurnState = (): TurnState => ({ reasoningActive: false, reasoningStreaming: false, reasoningTokens: 0, + streamPendingTools: [], + streamSegments: [], streaming: '', subagents: [], toolTokens: 0, @@ -32,6 +34,8 @@ export interface TurnState { reasoningActive: boolean reasoningStreaming: boolean reasoningTokens: number + streamPendingTools: string[] + streamSegments: Msg[] streaming: string subagents: SubagentProgress[] toolTokens: number diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 8fa7c10619..73ea9febda 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -568,6 +568,8 @@ export function useMainApp(gw: GatewayClient) { : Boolean( ui.busy || turn.outcome || + turn.streamPendingTools.length || + turn.streamSegments.length || turn.subagents.length || turn.tools.length || turn.turnTrail.length || diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index dcadb8226d..26d8e4b0a9 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -31,6 +31,10 @@ const StreamingAssistant = memo(function StreamingAssistant({ return ( <> + {progress.streamSegments.map((msg, i) => ( + + ))} + {progress.showProgressArea && ( + )} + + {!progress.showStreamingArea && !!progress.streamPendingTools.length && ( + )} From 6a37802476e21244184f36e39fed80d6fddf9317 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 17 Apr 2026 15:13:33 -0500 Subject: [PATCH 157/157] chore: uptick --- ui-tui/packages/hermes-ink/src/ink/ink.tsx | 80 +++++++++++++++++----- 1 file changed, 63 insertions(+), 17 deletions(-) diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index c9f90b6f98..1543dc7fce 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -241,6 +241,15 @@ export default class Ink { x: number y: number } | null = null + // Burst of SIGWINCH (vscode panel drag) → one React commit per + // microtask. Dims are captured sync in handleResize; only the + // expensive tree rebuild defers. + private pendingResizeRender = false + + // Fold synchronous re-entry (selection fanout, onFrame callback) + // into one follow-up microtask instead of stacking renders. + private isRendering = false + private immediateRerenderRequested = false constructor(private readonly options: Options) { autoBind(this) @@ -402,12 +411,10 @@ export default class Ink { this.displayCursor = null } - // NOT debounced. A debounce opens a window where stdout.columns is NEW - // but this.terminalColumns/Yoga are OLD — any scheduleRender during that - // window (spinner, clock) makes log-update detect a width change and - // clear the screen, then the debounce fires and clears again (double - // blank→paint flicker). useVirtualScroll's height scaling already bounds - // the per-resize cost; synchronous handling keeps dimensions consistent. + // Dims captured sync — closes the stale-dim window the original + // debounce rejection warned about. Expensive React commit defers to + // one microtask per burst: vscode fires many SIGWINCHes per panel + // drag, each ~80ms uncoalesced = event loop visibly locks up. private handleResize = () => { const cols = this.options.stdout.columns || 80 const rows = this.options.stdout.rows || 24 @@ -423,6 +430,15 @@ export default class Ink { this.terminalRows = rows this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + // Pending throttled/drain work captured stale dims — cancel so + // the upcoming microtask owns the next frame. + this.scheduleRender.cancel?.() + + if (this.drainTimer !== null) { + clearTimeout(this.drainTimer) + this.drainTimer = null + } + // Alt screen: reset frame buffers so the next render repaints from // scratch (prevFrameContaminated → every cell written, wrapped in // BSU/ESU — old content stays visible until the new frame swaps @@ -442,14 +458,24 @@ export default class Ink { this.needsEraseBeforePaint = true } - // Re-render the React tree with updated props so the context value changes. - // React's commit phase will call onComputeLayout() to recalculate yoga layout - // with the new dimensions, then call onRender() to render the updated frame. - // We don't call scheduleRender() here because that would render before the - // layout is updated, causing a mismatch between viewport and content dimensions. - if (this.currentNode !== null) { - this.render(this.currentNode) + // Already queued: later events in this burst updated dims/alt-screen + // prep above; the queued render picks up the latest values when it + // fires (React commit → onComputeLayout → scheduleRender → onRender). + if (this.pendingResizeRender) { + return } + + this.pendingResizeRender = true + + queueMicrotask(() => { + this.pendingResizeRender = false + + if (this.isUnmounted || this.currentNode === null) { + return + } + + this.render(this.currentNode) + }) } resolveExitPromise: () => void = () => {} rejectExitPromise: (reason?: Error) => void = () => {} @@ -536,6 +562,17 @@ export default class Ink { return } + // Fold synchronous re-entry (selection fanout, onFrame callback) + // into one follow-up microtask — back-to-back renders within one + // macrotask were the freeze multiplier. + if (this.isRendering) { + this.immediateRerenderRequested = true + + return + } + + this.isRendering = true + // Entering a render cancels any pending drain tick — this render will // handle the drain (and re-schedule below if needed). Prevents a // wheel-event-triggered render AND a drain-timer render both firing. @@ -906,10 +943,12 @@ export default class Ink { // are only ever true in alt-screen; in main-screen this is false→false. this.prevFrameContaminated = selActive || hlActive || !!frame.absoluteOverlayMoved - // Schedule corrective frame for scroll drain or absolute overlay resize. - // Plain timeout instead of scheduleRender to avoid double-render from - // lodash throttle's leadingEdge firing inside a trailing invocation. - if (frame.scrollDrainPending || frame.absoluteOverlayMoved) { + // Plain setTimeout (not scheduleRender) — lodash throttle's leading + // edge would fire inside this trailing invocation and double-render. + // Scroll drain only; absolute-overlay movement rides prevFrameContaminated + // into the next natural render. Routing it here made caret re-layout a + // 250fps self-oscillator that locked the event loop after resize. + if (frame.scrollDrainPending) { this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2) } @@ -942,6 +981,13 @@ export default class Ink { }, flickers }) + + this.isRendering = false + + if (this.immediateRerenderRequested) { + this.immediateRerenderRequested = false + queueMicrotask(() => this.onRender()) + } } pause(): void { // Flush pending React updates and render before pausing.